From 926a5d2b4a94932ff004eaed886501c03b2bf057 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 23:33:32 +0000 Subject: [PATCH 01/21] Add a complete update flow for the app, rules, and Mihomo. Check and Install now cover the signed Tauri package plus the versioned Iran rule snapshot and a missing bundled Mihomo binary. A rule-sync failure keeps the last good cache and does not block an app update. Co-authored-by: Dariush vesal --- Cargo.lock | 22 +- Cargo.toml | 2 +- apps/desktop/package.json | 2 +- apps/desktop/src/api/mock.ts | 12 +- apps/desktop/src/api/models.ts | 6 + apps/desktop/src/components/About.tsx | 8 + apps/desktop/src/i18n/config.ts | 10 +- apps/desktop/src/store/app.test.ts | 3 + apps/desktop/src/store/app.ts | 9 + crates/iran-split-rules/src/cloud.rs | 21 +- docs/adr/0039-complete-update-channels.md | 30 +++ docs/adr/README.md | 1 + docs/considering/desktop-sixteen-task-plan.md | 45 ++++ package.json | 2 +- scripts/tauri-contract.test.mjs | 9 +- src-tauri/src/lib.rs | 214 ++++++++++++++++-- src-tauri/tauri.conf.json | 2 +- version | 2 +- 18 files changed, 366 insertions(+), 34 deletions(-) create mode 100644 docs/adr/0039-complete-update-channels.md create mode 100644 docs/considering/desktop-sixteen-task-plan.md diff --git a/Cargo.lock b/Cargo.lock index 30fa88b..49c91cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1950,7 +1950,7 @@ dependencies = [ [[package]] name = "iran-split-cli" -version = "3.0.1" +version = "3.1.0" dependencies = [ "async-trait", "chrono", @@ -1967,7 +1967,7 @@ dependencies = [ [[package]] name = "iran-split-config" -version = "3.0.1" +version = "3.1.0" dependencies = [ "hex", "rand 0.9.5", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "iran-split-core" -version = "3.0.1" +version = "3.1.0" dependencies = [ "async-trait", "chrono", @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "iran-split-desktop" -version = "3.0.1" +version = "3.1.0" dependencies = [ "chrono", "dirs 6.0.0", @@ -2028,7 +2028,7 @@ dependencies = [ [[package]] name = "iran-split-helper" -version = "3.0.1" +version = "3.1.0" dependencies = [ "clap", "hex", @@ -2049,7 +2049,7 @@ dependencies = [ [[package]] name = "iran-split-helper-winacl" -version = "3.0.1" +version = "3.1.0" dependencies = [ "tokio", "windows", @@ -2057,7 +2057,7 @@ dependencies = [ [[package]] name = "iran-split-ipc" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bytes", "chrono", @@ -2070,7 +2070,7 @@ dependencies = [ [[package]] name = "iran-split-mihomo" -version = "3.0.1" +version = "3.1.0" dependencies = [ "chrono", "futures-util", @@ -2092,7 +2092,7 @@ dependencies = [ [[package]] name = "iran-split-platform-linux" -version = "3.0.1" +version = "3.1.0" dependencies = [ "async-trait", "chrono", @@ -2114,7 +2114,7 @@ dependencies = [ [[package]] name = "iran-split-platform-win" -version = "3.0.1" +version = "3.1.0" dependencies = [ "async-trait", "chrono", @@ -2135,7 +2135,7 @@ dependencies = [ [[package]] name = "iran-split-rules" -version = "3.0.1" +version = "3.1.0" dependencies = [ "async-trait", "chrono", diff --git a/Cargo.toml b/Cargo.toml index bc74c88..6183f68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "3.0.1" +version = "3.1.0" 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 3045adc..bb7681c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@iran-split/desktop", - "version": "3.0.1", + "version": "3.1.0", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src/api/mock.ts b/apps/desktop/src/api/mock.ts index 5c1df6c..290e7a4 100644 --- a/apps/desktop/src/api/mock.ts +++ b/apps/desktop/src/api/mock.ts @@ -748,9 +748,19 @@ export const mockApi = { available: true, version: "9.9.9", notes: "Mock signed release", + app_available: true, + rules_available: false, + thirdparty_available: false, }; } - return { available: false, version: null, notes: null }; + return { + available: false, + version: null, + notes: null, + app_available: false, + rules_available: false, + thirdparty_available: false, + }; }, async installUpdate(): Promise { if (mockUpdateShouldFail()) { diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts index dbe8bf5..3da0571 100644 --- a/apps/desktop/src/api/models.ts +++ b/apps/desktop/src/api/models.ts @@ -208,6 +208,9 @@ export interface UpdateStatus { available: boolean; version: string | null; notes: string | null; + app_available: boolean; + rules_available: boolean; + thirdparty_available: boolean; } export type UpdatePhase = @@ -226,6 +229,9 @@ export interface UpdateProgress { percent: number | null; version: string | null; error: string | null; + app_available?: boolean; + rules_available?: boolean; + thirdparty_available?: boolean; } export interface CloudRuleSetStatus { diff --git a/apps/desktop/src/components/About.tsx b/apps/desktop/src/components/About.tsx index 6e5af2e..2614bac 100644 --- a/apps/desktop/src/components/About.tsx +++ b/apps/desktop/src/components/About.tsx @@ -76,6 +76,14 @@ export function About() {

{updateMessage(t, update)}

+ {update.rules_available ? ( +

{t("updateRulesAvailable")}

+ ) : null} + {update.thirdparty_available ? ( +

+ {t("updateThirdpartyAvailable")} +

+ ) : null} {update.phase === "downloading" && update.percent !== null ? (
diff --git a/apps/desktop/src/i18n/config.ts b/apps/desktop/src/i18n/config.ts index a263594..8069328 100644 --- a/apps/desktop/src/i18n/config.ts +++ b/apps/desktop/src/i18n/config.ts @@ -68,7 +68,10 @@ const resources = { aboutVersion: "Version {{version}}", aboutUpdatesTitle: "Updates", aboutUpdatesHelp: - "Check GitHub Releases for signed BiFlow updates when they are published.", + "Check GitHub Releases for signed BiFlow updates, then refresh Iran rules and the bundled Mihomo build.", + updateRulesAvailable: "A newer Iran rule snapshot is available.", + updateThirdpartyAvailable: + "A bundled Mihomo build is missing and will be installed.", updateCheck: "Check for updates", updateChecking: "Checking…", updateCurrent: "You are on the latest published version.", @@ -203,7 +206,10 @@ const resources = { aboutVersion: "نسخه {{version}}", aboutUpdatesTitle: "به‌روزرسانی", aboutUpdatesHelp: - "وقتی منتشر شود، به‌روزرسانی‌های امضاشده BiFlow را از GitHub Releases بررسی کنید.", + "به‌روزرسانی امضاشده BiFlow را از GitHub Releases بررسی کنید، سپس قوانین ایران و بسته Mihomo را تازه کنید.", + updateRulesAvailable: "نسخه تازه‌تری از قوانین ایران آماده است.", + updateThirdpartyAvailable: + "بسته Mihomo همراه برنامه موجود نیست و نصب خواهد شد.", updateCheck: "بررسی به‌روزرسانی", updateChecking: "در حال بررسی…", updateCurrent: "آخرین نسخه منتشرشده را دارید.", diff --git a/apps/desktop/src/store/app.test.ts b/apps/desktop/src/store/app.test.ts index bb1b945..8a80b68 100644 --- a/apps/desktop/src/store/app.test.ts +++ b/apps/desktop/src/store/app.test.ts @@ -197,6 +197,9 @@ describe("app store", () => { available: true, version: "1.3.0", notes: "Signed release", + app_available: true, + rules_available: false, + thirdparty_available: false, }); await useAppStore.getState().checkForUpdate(); expect(useAppStore.getState().update.phase).toBe("available"); diff --git a/apps/desktop/src/store/app.ts b/apps/desktop/src/store/app.ts index 4573e92..1061c70 100644 --- a/apps/desktop/src/store/app.ts +++ b/apps/desktop/src/store/app.ts @@ -21,6 +21,9 @@ const initialUpdateProgress = (): UpdateProgress => ({ percent: null, version: null, error: null, + app_available: false, + rules_available: false, + thirdparty_available: false, }); interface AppStore { @@ -354,6 +357,9 @@ function updateStatusToProgress(status: UpdateStatus): UpdateProgress { percent: null, version: null, error: null, + app_available: false, + rules_available: false, + thirdparty_available: false, }; } return { @@ -361,5 +367,8 @@ function updateStatusToProgress(status: UpdateStatus): UpdateProgress { percent: null, version: status.version, error: null, + app_available: status.app_available, + rules_available: status.rules_available, + thirdparty_available: status.thirdparty_available, }; } diff --git a/crates/iran-split-rules/src/cloud.rs b/crates/iran-split-rules/src/cloud.rs index ff95ba6..b1a977b 100644 --- a/crates/iran-split-rules/src/cloud.rs +++ b/crates/iran-split-rules/src/cloud.rs @@ -296,6 +296,23 @@ impl CloudRuleStore { self.bundled_status() } + /// Returns the last published snapshot revision, if the cache has one. + #[must_use] + pub fn cached_revision(&self) -> Option { + self.read_meta() + .ok() + .and_then(|meta| meta.snapshot_revision) + } + + /// Fetches the `BiFlow` manifest and returns its snapshot revision. + /// + /// # Errors + /// + /// Returns [`CloudSyncError`] when the manifest cannot be downloaded or decoded. + pub async fn peek_remote_revision(&self) -> Result { + Ok(self.fetch_manifest().await?.commit) + } + /// Downloads, validates, and atomically publishes every cloud rule set. /// /// # Errors @@ -758,7 +775,9 @@ mod tests { let kept = failing.status().expect("status"); assert_eq!(kept.domain_count, 1_000); assert_eq!(kept.source, BIFLOW_REPOSITORY); - assert_eq!(kept.snapshot_revision, Some(commit)); + assert_eq!(kept.snapshot_revision, Some(commit.clone())); + assert_eq!(failing.cached_revision(), Some(commit.clone())); + assert_eq!(store.peek_remote_revision().await.expect("peek"), commit); } #[tokio::test] diff --git a/docs/adr/0039-complete-update-channels.md b/docs/adr/0039-complete-update-channels.md new file mode 100644 index 0000000..c7f0e00 --- /dev/null +++ b/docs/adr/0039-complete-update-channels.md @@ -0,0 +1,30 @@ +# ADR 0039: Complete update channels + +## Status + +Accepted + +## Context + +`check_for_update` and `install_update` only covered the signed BiFlow package +(AppImage / NSIS). Iran rule snapshots and the bundled Mihomo binary are also +versioned assets, but they lived on separate manual buttons. Operators expected +one Install action to refresh the app and those sidecars. + +## Decision + +- Keep the Tauri updater plugin as the only path that replaces the application + binary. Linux `.deb` still opens the Release page (ADR 0024). +- `check_for_update` also compares the cached rule revision to the BiFlow + manifest and reports a missing bundled Mihomo as a third-party channel. +- `install_update` first syncs cloud rules and installs Mihomo when it is + missing, then runs the signed self-replace when an app update exists. +- A rule-sync failure keeps the last good snapshot and does not block an + application update. Mihomo install failure is fatal for that step. +- When only sidecars change, Install finishes without restarting. + +## Consequences + +- About can show pending rule and Mihomo work beside a signed app version. +- Background polling uses the same combined status so a sidecar-only refresh + can surface the Install button without an error banner. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7861c55..09a5260 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -42,3 +42,4 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th | [0036](./0036-windows-helper-task-xml.md) | Windows helper scheduled task XML | Accepted | | [0037](./0037-windows-mihomo-controller-reachability.md) | Windows Mihomo controller reachability | Accepted | | [0038](./0038-windows-tun-readiness.md) | Windows TUN readiness and clash alignment | Accepted | +| [0039](./0039-complete-update-channels.md) | Complete update channels | Accepted | diff --git a/docs/considering/desktop-sixteen-task-plan.md b/docs/considering/desktop-sixteen-task-plan.md new file mode 100644 index 0000000..7c94b25 --- /dev/null +++ b/docs/considering/desktop-sixteen-task-plan.md @@ -0,0 +1,45 @@ +# Implementation plan: updater, rules, responsive shell, tray + +Assessment of the current tree (2026-08-15) and the work for the sixteen +requested tasks. Runtime changes land in numbered commits on this branch. + +## Current state + +| Area | Today | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| App updater | Signed Tauri plugin, AppImage/NSIS self-replace, `.deb` opens Releases. Check retries 4×. Install-time `check()` has no retry, no mutex, no timeout. | +| Rules / third-party | Cloud sync is manual, SHA-validated, last-good fallback. No HTTP retry, per-file persist (not a staging generation). Mihomo/Hiddify are install-once, not part of `install_update`. | +| Shell | Fixed 1120×760, sidebar only, no bottom nav, Dashboard `overflow-hidden`, status bar Advanced-only. | +| About Basic | `UiModeSwitch` works, but Basic + `page === "about"` keeps About instead of BasicDashboard. | +| IP / traffic | IP is display-only. No byte counters. | +| Connect | Does not auto-install helper/Hiddify/Mihomo. | +| First launch | Default **Advanced** (ADR 0023). | +| Glow | 2px, `border-radius: 0.5rem`. | +| Window | Not resizable; size not persisted. | +| Context menu | Global `preventDefault` on every `contextmenu`. | +| Tray | Eight static items; Connect/Pause/Resume/Disconnect all visible. | + +## Per-task approach + +1. **Complete update flow** — Extend `check_for_update` / `install_update` so a single operator action covers the signed app package plus versioned Iran rules and bundled Mihomo. Keep Tauri updater for the binary. +2. **Reliable update check** — Per-attempt timeout, try-lock concurrency, install-time retry, download timeout. Background poll stays silent on failure. +3. **Reliable rule sync** — Fetch retry + backoff + direct fallback, staging directory, publish files then meta, last-good cache on any failure. +4. **Responsive UI** — CSS/layout breakpoints. Viewport `<768`: bottom nav (not hamburger). Status bar and bottom nav are stacked siblings, never overlaid. +5. **About Basic** — Switching to Basic sets `page` to `dashboard` so the same control matches other pages. +6. **Dashboard scroll** — `overflow-y-auto` on the Dashboard section. +7. **Sticky status bar** — Always rendered (Basic and Advanced), flex sibling after scroll content, `shrink-0`. +8. **Clickable IP** — Button refreshes network status; in-flight guard; checking spinner. +9. **Traffic totals** — Mihomo `/connections` totals folded into a persisted lifetime file so disconnect/reconnect does not zero the bar. +10. **Connect installs deps** — Before `start()`, sequentially install missing helper → Hiddify → Mihomo; abort with the step error. +11. **First-launch Basic** — `readUiMode()` defaults to `basic`; Connect-install from (10) runs in Basic. +12. **Glow** — `border-radius: 0`, border 3px, stronger inset/outer glow. Overlay still, not a layout-shifting shell border. +13. **Window size** — Resizable; min 390×640; default 1120×760; persist logical size; clamp to work area. +14. **Three viewports** — Playwright + browser screenshots at 390×844, 768×1024, 1024×768; fix clipping. +15. **Input context menu** — Allow `contextmenu` on text/number inputs; Select All / Copy / Cut / Paste; disable when inapplicable. +16. **Tray** — Exactly Connect\|Disconnect, Pause\|Resume, Quit, with separators; rebuild on every snapshot. + +## Platform notes + +- Linux AppImage and Windows NSIS self-replace; `.deb` stays manual (ADR 0024). +- Native Windows UI is verified with `cargo xwin clippy` / contract tests in this environment, not a Windows desktop session. +- Helper install still needs an interactive polkit/UAC prompt; Connect-install surfaces that error instead of hanging. diff --git a/package.json b/package.json index c7130d7..c8ceea4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iran-split-desktop-workspace", - "version": "3.0.1", + "version": "3.1.0", "private": true, "packageManager": "pnpm@9.0.1", "engines": { diff --git a/scripts/tauri-contract.test.mjs b/scripts/tauri-contract.test.mjs index ec2fe26..3f4b53c 100644 --- a/scripts/tauri-contract.test.mjs +++ b/scripts/tauri-contract.test.mjs @@ -272,7 +272,11 @@ describe("Tauri frontend contract", () => { // The command must go through the retry, not call the plugin directly. assert.match( rust, - /async fn check_for_update\([\s\S]*?check_update_with_retry\(&app, "tauri_command"\)/, + /async fn check_for_update\([\s\S]*?collect_update_status\(&app, "tauri_command"\)/, + ); + assert.match( + rust, + /async fn collect_update_status\([\s\S]*?check_update_with_retry\(app, initiator\)/, ); assert.match(rust, /fn spawn_background_update_checks\(/); assert.match(rust, /spawn_background_update_checks\(app\.handle\(\)\)/); @@ -286,6 +290,9 @@ describe("Tauri frontend contract", () => { // Signed self-replacement, not a browser link. assert.match(rust, /update\s*\.download_and_install\(/); assert.match(rust, /fn schedule_update_restart\(/); + assert.match(rust, /fn perform_complete_update_install\(/); + assert.match(rust, /fn apply_sidecar_updates\(/); + assert.match(rust, /fn merge_update_channels\(/); }); it("never reports a previous attempt's Windows install reason", () => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04f64dc..bd9cc7f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -363,10 +363,17 @@ struct ExportResult { } #[derive(Debug, Clone, Serialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "IPC shape matches the About page channel flags" +)] struct UpdateStatus { available: bool, version: Option, notes: Option, + app_available: bool, + rules_available: bool, + thirdparty_available: bool, } #[derive(Debug, Clone, Serialize)] @@ -1157,15 +1164,114 @@ async fn check_update_once(app: &AppHandle) -> Result { available: false, version: None, notes: None, + app_available: false, + rules_available: false, + thirdparty_available: false, }, |update| UpdateStatus { available: true, version: Some(update.version.clone()), notes: update.body.clone(), + app_available: true, + rules_available: false, + thirdparty_available: false, }, )) } +fn merge_update_channels( + mut status: UpdateStatus, + rules_available: bool, + thirdparty_available: bool, +) -> UpdateStatus { + status.rules_available = rules_available; + status.thirdparty_available = thirdparty_available; + status.available = status.app_available || rules_available || thirdparty_available; + status +} + +async fn enrich_update_channels(app: &AppHandle, status: &mut UpdateStatus) { + let Ok(services) = services(app) else { + return; + }; + match services.cloud_rules.peek_remote_revision().await { + Ok(remote) => { + status.rules_available = + services.cloud_rules.cached_revision().as_deref() != Some(remote.as_str()); + } + Err(cause) => { + warn!( + event = "update.rules_probe_failed", + section = "updates", + initiator = "check_for_update", + cause = %cause, + trace_route = "updater->cloud_rule_store->manifest", + "rule snapshot revision could not be compared" + ); + } + } + let thirdparty_available = deps::dependency_status(&services.paths.data) + .into_iter() + .any(|item| item.id == "mihomo" && !item.installed); + *status = merge_update_channels(status.clone(), status.rules_available, thirdparty_available); +} + +async fn collect_update_status( + app: &AppHandle, + initiator: &'static str, +) -> Result { + let mut status = check_update_with_retry(app, initiator).await?; + enrich_update_channels(app, &mut status).await; + Ok(status) +} + +async fn apply_sidecar_updates(app: &AppHandle, operation_id: Uuid) -> Result<(), String> { + let services = services(app)?; + info!( + event = "update.sidecars_started", + section = "updates", + initiator = "install_update", + cause = "versioned_assets", + trace_route = "tauri_command->cloud_rules->mihomo_install", + trace_id = %operation_id, + "applying versioned rule and third-party updates" + ); + emit_update_progress( + app, + UpdateProgress { + phase: "installing".into(), + percent: Some(10), + version: None, + error: None, + }, + ); + if let Err(cause) = services.cloud_rules.sync().await { + warn!( + event = "update.rules_sync_failed", + section = "updates", + initiator = "install_update", + cause = %cause, + trace_route = "install_update->cloud_rule_store->sync", + trace_id = %operation_id, + "cloud rule update failed; last good snapshot remains" + ); + } + let mihomo_missing = deps::dependency_status(&services.paths.data) + .into_iter() + .any(|item| item.id == "mihomo" && !item.installed); + if mihomo_missing { + deps::install_dependency( + deps::DependencyId::Mihomo, + &services.paths.data, + &services.paths.dependencies, + ) + .await + .map_err(|error| error.to_string())?; + services.engine.refresh_health().await; + } + Ok(()) +} + /// Doubles the wait after each failed attempt. Attempt `0` is immediate. #[must_use] fn update_check_backoff(attempt: u32) -> Duration { @@ -1226,7 +1332,7 @@ async fn check_update_with_retry( #[tauri::command] async fn check_for_update(app: AppHandle) -> Result { diagnostics::trace_action("updates", "tauri_command", "check_for_update", async move { - check_update_with_retry(&app, "tauri_command").await + collect_update_status(&app, "tauri_command").await }) .await } @@ -1239,7 +1345,7 @@ fn spawn_background_update_checks(app: &AppHandle) { tauri::async_runtime::spawn(async move { tokio::time::sleep(UPDATE_BACKGROUND_DELAY).await; loop { - match check_update_with_retry(&app, "background_poll").await { + match collect_update_status(&app, "background_poll").await { Ok(status) if status.available => { info!( event = "update.background_found", @@ -1350,6 +1456,34 @@ fn schedule_update_restart(app: &AppHandle, operation_id: Uuid) -> OperationAcce } } +async fn perform_complete_update_install( + app: &AppHandle, + operation_id: Uuid, +) -> Result { + apply_sidecar_updates(app, operation_id).await?; + let status = collect_update_status(app, "install_update").await?; + if !status.app_available { + emit_update_progress( + app, + UpdateProgress { + phase: if status.available { + "available".into() + } else { + "current".into() + }, + percent: Some(100), + version: status.version, + error: None, + }, + ); + return Ok(OperationAccepted { + operation_id, + already_complete: true, + }); + } + perform_signed_update_install(app, operation_id).await +} + async fn perform_signed_update_install( app: &AppHandle, operation_id: Uuid, @@ -1359,13 +1493,17 @@ async fn perform_signed_update_install( if !linux_updater_self_replace_supported() { return open_linux_deb_release(app, operation_id); } - let update = app - .updater() - .map_err(|error| error.to_string())? - .check() - .await - .map_err(|error| error.to_string())? - .ok_or("no update is available")?; + let update = match check_update_with_retry(app, "install_update").await { + Ok(status) if status.app_available => app + .updater() + .map_err(|error| error.to_string())? + .check() + .await + .map_err(|error| error.to_string())? + .ok_or("no update is available")?, + Ok(_) => return Err("no update is available".into()), + Err(error) => return Err(error), + }; let target_version = update.version.clone(); info!( event = "update.download_started", @@ -1403,7 +1541,7 @@ async fn perform_signed_update_install( #[tauri::command] async fn install_update(app: AppHandle) -> Result { diagnostics::trace_action("updates", "tauri_command", "install_update", async move { - perform_signed_update_install(&app, Uuid::new_v4()).await + perform_complete_update_install(&app, Uuid::new_v4()).await }) .await } @@ -2049,9 +2187,9 @@ pub fn run() { #[cfg(test)] mod tests { use super::{ - packaged_rule_snapshot_dir, single_instance_dbus_id, update_check_backoff, - update_download_percent, UpdateProgress, BUNDLE_IDENTIFIER, UPDATE_CHECK_ATTEMPTS, - UPDATE_CHECK_FIRST_BACKOFF, + merge_update_channels, packaged_rule_snapshot_dir, single_instance_dbus_id, + update_check_backoff, update_download_percent, UpdateProgress, UpdateStatus, + BUNDLE_IDENTIFIER, UPDATE_CHECK_ATTEMPTS, UPDATE_CHECK_FIRST_BACKOFF, }; use std::{fs, time::Duration}; @@ -2107,6 +2245,56 @@ mod tests { ); } + #[test] + fn merge_update_channels_marks_any_pending_channel() { + let none = merge_update_channels( + UpdateStatus { + available: false, + version: None, + notes: None, + app_available: false, + rules_available: false, + thirdparty_available: false, + }, + false, + false, + ); + assert!(!none.available); + + let rules_only = merge_update_channels( + UpdateStatus { + available: false, + version: None, + notes: None, + app_available: false, + rules_available: false, + thirdparty_available: false, + }, + true, + false, + ); + assert!(rules_only.available); + assert!(rules_only.rules_available); + assert!(!rules_only.app_available); + + let app = merge_update_channels( + UpdateStatus { + available: true, + version: Some("3.1.0".into()), + notes: None, + app_available: true, + rules_available: false, + thirdparty_available: false, + }, + false, + true, + ); + assert!(app.available); + assert!(app.app_available); + assert!(app.thirdparty_available); + assert_eq!(app.version.as_deref(), Some("3.1.0")); + } + #[test] fn update_progress_serializes_expected_phases() { let progress = UpdateProgress { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 08a2c36..0fdc4d1 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": "3.0.1", + "version": "3.1.0", "identifier": "app.biflow.desktop", "build": { "beforeDevCommand": "pnpm bundle:check && pnpm --dir apps/desktop dev", diff --git a/version b/version index cb2b00e..fd2a018 100644 --- a/version +++ b/version @@ -1 +1 @@ -3.0.1 +3.1.0 From f599acb2bc5d53a8a96408a2796abee2e1eeb728 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 23:36:00 +0000 Subject: [PATCH 02/21] Make update checks time out, cancel, and refuse overlap. Each GitHub check is bounded to 8s, download/install to 10 minutes, and a process lock plus store guard stop stacked Check/Install calls. Cancel also stops the update retry loop. Co-authored-by: Dariush vesal --- apps/desktop/src/store/app.test.ts | 30 +++++ apps/desktop/src/store/app.ts | 18 +++ docs/adr/0040-reliable-update-check.md | 30 +++++ docs/adr/README.md | 1 + scripts/tauri-contract.test.mjs | 4 + src-tauri/src/lib.rs | 161 +++++++++++++++++++------ 6 files changed, 210 insertions(+), 34 deletions(-) create mode 100644 docs/adr/0040-reliable-update-check.md diff --git a/apps/desktop/src/store/app.test.ts b/apps/desktop/src/store/app.test.ts index 8a80b68..7a48d17 100644 --- a/apps/desktop/src/store/app.test.ts +++ b/apps/desktop/src/store/app.test.ts @@ -211,6 +211,36 @@ describe("app store", () => { expect(useAppStore.getState().update.error).toMatch(/bad manifest/); }); + it("ignores a second update check while one is already in flight", async () => { + let finish: (status: { + available: boolean; + version: string | null; + notes: string | null; + app_available: boolean; + rules_available: boolean; + thirdparty_available: boolean; + }) => void = () => undefined; + vi.mocked(desktop.checkUpdate).mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const first = useAppStore.getState().checkForUpdate(); + await useAppStore.getState().checkForUpdate(); + expect(desktop.checkUpdate).toHaveBeenCalledOnce(); + finish({ + available: false, + version: null, + notes: null, + app_available: false, + rules_available: false, + thirdparty_available: false, + }); + await first; + expect(useAppStore.getState().update.phase).toBe("current"); + }); + it("retries install after a failed update when a version is known", async () => { vi.mocked(desktop.installUpdate).mockResolvedValue({ operation_id: "update-op", diff --git a/apps/desktop/src/store/app.ts b/apps/desktop/src/store/app.ts index 1061c70..8b643db 100644 --- a/apps/desktop/src/store/app.ts +++ b/apps/desktop/src/store/app.ts @@ -277,6 +277,15 @@ export const useAppStore = create((set, get) => ({ set({ update: progress }); }, checkForUpdate: async () => { + const phase = get().update.phase; + if ( + phase === "checking" || + phase === "downloading" || + phase === "installing" || + phase === "restarting" + ) { + return; + } set({ update: { phase: "checking", @@ -302,6 +311,15 @@ export const useAppStore = create((set, get) => ({ } }, installUpdate: async () => { + const phase = get().update.phase; + if ( + phase === "checking" || + phase === "downloading" || + phase === "installing" || + phase === "restarting" + ) { + return; + } const current = get().update; set({ update: { diff --git a/docs/adr/0040-reliable-update-check.md b/docs/adr/0040-reliable-update-check.md new file mode 100644 index 0000000..2e05444 --- /dev/null +++ b/docs/adr/0040-reliable-update-check.md @@ -0,0 +1,30 @@ +# ADR 0040: Reliable update check + +## Status + +Accepted + +## Context + +`check_for_update` could sit on a hung GitHub request until the operator +force-quit. A second Check or Install could start another plugin call. Cancel +only stopped the routing stack. + +## Decision + +- Bound each plugin `check()` with an 8-second timeout and keep the existing + four attempts plus exponential backoff. +- Hold a process-wide update lock (`try_lock`) so Check, Install, and the + background poll never overlap. A busy lock returns a clear error (or is + skipped for the background poll). +- `cancel_operation` also sets the update cancel flag so a retry loop exits + instead of sleeping out the remaining attempts. +- Bound `download_and_install` with a ten-minute timeout. +- The About store ignores a second Check/Install while a phase is already + in flight, so the UI does not stack progress states. + +## Consequences + +- A dead resolver no longer freezes About; the operator sees a timeout after + the retry budget. +- Sidecar work from ADR 0039 shares the same lock as the signed package. diff --git a/docs/adr/README.md b/docs/adr/README.md index 09a5260..74038be 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -43,3 +43,4 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th | [0037](./0037-windows-mihomo-controller-reachability.md) | Windows Mihomo controller reachability | Accepted | | [0038](./0038-windows-tun-readiness.md) | Windows TUN readiness and clash alignment | Accepted | | [0039](./0039-complete-update-channels.md) | Complete update channels | Accepted | +| [0040](./0040-reliable-update-check.md) | Reliable update check | Accepted | diff --git a/scripts/tauri-contract.test.mjs b/scripts/tauri-contract.test.mjs index 3f4b53c..e7baf5b 100644 --- a/scripts/tauri-contract.test.mjs +++ b/scripts/tauri-contract.test.mjs @@ -293,6 +293,10 @@ describe("Tauri frontend contract", () => { assert.match(rust, /fn perform_complete_update_install\(/); assert.match(rust, /fn apply_sidecar_updates\(/); assert.match(rust, /fn merge_update_channels\(/); + assert.match(rust, /const UPDATE_CHECK_ATTEMPT_TIMEOUT/); + assert.match(rust, /const UPDATE_INSTALL_TIMEOUT/); + assert.match(rust, /fn update_in_progress_message\(/); + assert.match(rust, /updates\.begin\(\)/); }); it("never reports a previous attempt's Windows install reason", () => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bd9cc7f..8f92ffe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,7 +16,10 @@ use serde::Serialize; use std::{ fs, path::{Path, PathBuf}, - sync::Arc, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, time::Duration, }; use tauri::{ @@ -42,6 +45,57 @@ struct AppServices { cloud_rules: CloudRuleStore, network: network::NetworkMonitor, paths: AppPaths, + updates: Arc, +} + +struct UpdateCoordinator { + lock: tokio::sync::Mutex<()>, + cancel: AtomicBool, +} + +impl std::fmt::Debug for UpdateCoordinator { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("UpdateCoordinator") + .field("cancel", &self.cancel.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } +} + +impl UpdateCoordinator { + fn new() -> Self { + Self { + lock: tokio::sync::Mutex::new(()), + cancel: AtomicBool::new(false), + } + } + + fn request_cancel(&self) { + self.cancel.store(true, Ordering::SeqCst); + } + + fn begin(&self) -> Result, String> { + let guard = self + .lock + .try_lock() + .map_err(|_| update_in_progress_message())?; + self.cancel.store(false, Ordering::SeqCst); + Ok(guard) + } + + fn is_cancelled(&self) -> bool { + self.cancel.load(Ordering::SeqCst) + } +} + +fn update_in_progress_message() -> String { + "an update is already in progress".into() +} + +fn update_check_cancelled(app: &AppHandle) -> bool { + services(app) + .ok() + .is_some_and(|services| services.updates.is_cancelled()) } #[derive(Debug, Clone)] @@ -598,7 +652,9 @@ async fn restart_stack(app: AppHandle) -> Result { async fn cancel_operation(app: AppHandle, operation_id: Uuid) -> Result { diagnostics::trace_action("stack", "tauri_command", "cancel_operation", async move { info!(operation_id = %operation_id, "operation cancellation requested"); - Ok(services(&app)?.engine.cancel_operation(operation_id).await) + let services = services(&app)?; + services.updates.request_cancel(); + Ok(services.engine.cancel_operation(operation_id).await) }) .await } @@ -1147,18 +1203,23 @@ fn export_support_bundle(app: AppHandle) -> Result { /// becomes a Retry button the operator has to press. const UPDATE_CHECK_ATTEMPTS: u32 = 4; const UPDATE_CHECK_FIRST_BACKOFF: Duration = Duration::from_millis(600); +const UPDATE_CHECK_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(8); +const UPDATE_INSTALL_TIMEOUT: Duration = Duration::from_secs(10 * 60); /// How long to wait after launch before the first background check, and the /// interval between later ones. const UPDATE_BACKGROUND_DELAY: Duration = Duration::from_secs(90); const UPDATE_BACKGROUND_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); async fn check_update_once(app: &AppHandle) -> Result { - let update = app - .updater() - .map_err(|error| error.to_string())? - .check() - .await - .map_err(|error| error.to_string())?; + let update = tokio::time::timeout(UPDATE_CHECK_ATTEMPT_TIMEOUT, async { + app.updater() + .map_err(|error| error.to_string())? + .check() + .await + .map_err(|error| error.to_string()) + }) + .await + .map_err(|_| "update check timed out".to_owned())??; Ok(update.map_or( UpdateStatus { available: false, @@ -1284,6 +1345,9 @@ async fn check_update_with_retry( ) -> Result { let mut last_error = String::new(); for attempt in 0..UPDATE_CHECK_ATTEMPTS { + if update_check_cancelled(app) { + return Err("update check cancelled".into()); + } match check_update_once(app).await { Ok(status) => { if attempt > 0 { @@ -1314,6 +1378,9 @@ async fn check_update_with_retry( } } if attempt + 1 < UPDATE_CHECK_ATTEMPTS { + if update_check_cancelled(app) { + return Err("update check cancelled".into()); + } tokio::time::sleep(update_check_backoff(attempt)).await; } } @@ -1332,6 +1399,7 @@ async fn check_update_with_retry( #[tauri::command] async fn check_for_update(app: AppHandle) -> Result { diagnostics::trace_action("updates", "tauri_command", "check_for_update", async move { + let _guard = services(&app)?.updates.begin()?; collect_update_status(&app, "tauri_command").await }) .await @@ -1345,6 +1413,14 @@ fn spawn_background_update_checks(app: &AppHandle) { tauri::async_runtime::spawn(async move { tokio::time::sleep(UPDATE_BACKGROUND_DELAY).await; loop { + let Ok(services) = services(&app) else { + tokio::time::sleep(UPDATE_BACKGROUND_INTERVAL).await; + continue; + }; + let Ok(_guard) = services.updates.begin() else { + tokio::time::sleep(UPDATE_BACKGROUND_INTERVAL).await; + continue; + }; match collect_update_status(&app, "background_poll").await { Ok(status) if status.available => { info!( @@ -1391,33 +1467,34 @@ async fn download_and_install_signed_update( let mut downloaded = 0usize; let app_for_progress = app.clone(); let version_for_progress = target_version.to_owned(); - update - .download_and_install( - move |chunk_length, content_length| { - downloaded = downloaded.saturating_add(chunk_length); - emit_update_progress( - &app_for_progress, - UpdateProgress { - phase: "downloading".into(), - percent: update_download_percent(downloaded, content_length), - version: Some(version_for_progress.clone()), - error: None, - }, - ); - }, - || { - emit_update_progress( - app, - UpdateProgress { - phase: "installing".into(), - percent: Some(100), - version: Some(target_version.to_owned()), - error: None, - }, - ); - }, - ) + let download = update.download_and_install( + move |chunk_length, content_length| { + downloaded = downloaded.saturating_add(chunk_length); + emit_update_progress( + &app_for_progress, + UpdateProgress { + phase: "downloading".into(), + percent: update_download_percent(downloaded, content_length), + version: Some(version_for_progress.clone()), + error: None, + }, + ); + }, + || { + emit_update_progress( + app, + UpdateProgress { + phase: "installing".into(), + percent: Some(100), + version: Some(target_version.to_owned()), + error: None, + }, + ); + }, + ); + tokio::time::timeout(UPDATE_INSTALL_TIMEOUT, download) .await + .map_err(|_| "update download timed out".to_owned())? .map_err(|error| { let message = error.to_string(); emit_update_progress( @@ -1541,6 +1618,7 @@ async fn perform_signed_update_install( #[tauri::command] async fn install_update(app: AppHandle) -> Result { diagnostics::trace_action("updates", "tauri_command", "install_update", async move { + let _guard = services(&app)?.updates.begin()?; perform_complete_update_install(&app, Uuid::new_v4()).await }) .await @@ -1564,6 +1642,10 @@ fn write_json(path: &Path, value: &impl Serialize) -> Result<(), String> { .map_err(|error| error.to_string()) } +#[allow( + clippy::too_many_lines, + reason = "Linux and Windows backend construction stay in one startup path" +)] fn create_services(app: &AppHandle) -> Result { info!( event = "services.initializing", @@ -1666,6 +1748,7 @@ fn create_services(app: &AppHandle) -> Result { cloud_rules, network, paths, + updates: Arc::new(UpdateCoordinator::new()), }) } @@ -2193,6 +2276,16 @@ mod tests { }; use std::{fs, time::Duration}; + #[test] + fn update_check_attempt_timeout_bounds_a_hang() { + assert_eq!(super::UPDATE_CHECK_ATTEMPT_TIMEOUT, Duration::from_secs(8)); + assert_eq!(super::UPDATE_INSTALL_TIMEOUT, Duration::from_secs(10 * 60)); + assert_eq!( + super::update_in_progress_message(), + "an update is already in progress" + ); + } + #[test] fn update_check_backoff_grows_and_stays_bounded() { assert_eq!(update_check_backoff(0), UPDATE_CHECK_FIRST_BACKOFF); From cce64b6273e9840cf8e0a33c3aa4c558272cec3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 23:38:11 +0000 Subject: [PATCH 03/21] Make cloud rule sync retry, stage, and publish atomically. Fetches retry with backoff and a direct client fallback. A generation is written to staging first, then persisted into the cache with metadata last so a failure keeps the last good rule set. Co-authored-by: Dariush vesal --- apps/desktop/src/store/app.test.ts | 6 + apps/desktop/src/store/app.ts | 3 + crates/iran-split-rules/src/cloud.rs | 219 +++++++++++++++++++--- docs/adr/0041-reliable-cloud-rule-sync.md | 27 +++ docs/adr/README.md | 1 + 5 files changed, 229 insertions(+), 27 deletions(-) create mode 100644 docs/adr/0041-reliable-cloud-rule-sync.md diff --git a/apps/desktop/src/store/app.test.ts b/apps/desktop/src/store/app.test.ts index 7a48d17..eefe0f4 100644 --- a/apps/desktop/src/store/app.test.ts +++ b/apps/desktop/src/store/app.test.ts @@ -192,6 +192,12 @@ describe("app store", () => { expect(useAppStore.getState().actionPending).toBe(false); }); + it("does not start a second cloud rule sync while one is pending", async () => { + useAppStore.setState({ actionPending: true }); + await useAppStore.getState().syncCloudRules(); + expect(desktop.syncCloudRules).not.toHaveBeenCalled(); + }); + it("tracks available and failed update states", async () => { vi.mocked(desktop.checkUpdate).mockResolvedValue({ available: true, diff --git a/apps/desktop/src/store/app.ts b/apps/desktop/src/store/app.ts index 8b643db..4230c61 100644 --- a/apps/desktop/src/store/app.ts +++ b/apps/desktop/src/store/app.ts @@ -214,6 +214,9 @@ export const useAppStore = create((set, get) => ({ } }, syncCloudRules: async () => { + if (get().actionPending) { + return; + } set({ actionPending: true, error: null }); try { const cloudRules = await desktop.syncCloudRules(); diff --git a/crates/iran-split-rules/src/cloud.rs b/crates/iran-split-rules/src/cloud.rs index b1a977b..40d8260 100644 --- a/crates/iran-split-rules/src/cloud.rs +++ b/crates/iran-split-rules/src/cloud.rs @@ -13,7 +13,8 @@ use std::{ }; use tempfile::NamedTempFile; use thiserror::Error; -use tracing::info; +use tokio::sync::Mutex; +use tracing::{info, warn}; const BIFLOW_REPOSITORY: &str = "devlifeX/BiFlow"; const BIFLOW_MANIFEST_URL: &str = @@ -26,7 +27,10 @@ const BIFLOW_RAW_PREFIX: &str = "https://raw.githubusercontent.com/devlifeX/BiFl /// in the manifest, so integrity does not depend on the ref. const BIFLOW_SNAPSHOT_REF: &str = "main"; const META_FILE: &str = "sync-meta.json"; +const STAGING_DIR: &str = ".staging"; const MAX_BYTES: usize = 20 * 1024 * 1024; +const FETCH_ATTEMPTS: u32 = 3; +const FETCH_BACKOFF: Duration = Duration::from_millis(400); #[derive(Debug, Error)] pub enum CloudSyncError { @@ -165,33 +169,31 @@ pub trait RuleFetcher: Send + Sync { #[derive(Debug)] pub struct ReqwestFetcher { client: reqwest::Client, + direct: reqwest::Client, } impl ReqwestFetcher { fn new() -> Self { Self { - client: reqwest::Client::builder() - .user_agent("BiFlow/0.1.0") - .connect_timeout(Duration::from_secs(15)) - .timeout(Duration::from_secs(60)) - .redirect(reqwest::redirect::Policy::limited(8)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()), + client: Self::build_client(false), + direct: Self::build_client(true), } } -} -impl Default for ReqwestFetcher { - fn default() -> Self { - Self::new() + fn build_client(no_proxy: bool) -> reqwest::Client { + let mut builder = reqwest::Client::builder() + .user_agent("BiFlow/0.1.0") + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(60)) + .redirect(reqwest::redirect::Policy::limited(8)); + if no_proxy { + builder = builder.no_proxy(); + } + builder.build().unwrap_or_else(|_| reqwest::Client::new()) } -} -#[async_trait] -impl RuleFetcher for ReqwestFetcher { - async fn fetch(&self, url: &str) -> Result, CloudSyncError> { - let response = self - .client + async fn fetch_with(client: &reqwest::Client, url: &str) -> Result, CloudSyncError> { + let response = client .get(url) .send() .await @@ -210,6 +212,25 @@ impl RuleFetcher for ReqwestFetcher { } } +impl Default for ReqwestFetcher { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl RuleFetcher for ReqwestFetcher { + async fn fetch(&self, url: &str) -> Result, CloudSyncError> { + match Self::fetch_with(&self.client, url).await { + Ok(bytes) => Ok(bytes), + Err(proxy_error) => match Self::fetch_with(&self.direct, url).await { + Ok(bytes) => Ok(bytes), + Err(_) => Err(proxy_error), + }, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CloudRuleSetStatus { pub id: String, @@ -242,6 +263,7 @@ pub struct CloudRuleStore { bundled_dir: PathBuf, cache_dir: PathBuf, fetcher: Arc, + sync_lock: Arc>, } impl std::fmt::Debug for CloudRuleStore { @@ -270,6 +292,7 @@ impl CloudRuleStore { bundled_dir: bundled_dir.into(), cache_dir: cache_dir.into(), fetcher, + sync_lock: Arc::new(Mutex::new(())), } } @@ -321,6 +344,7 @@ impl CloudRuleStore { /// or atomic persistence failures. Existing cached rules remain available /// when a replacement cannot be published. pub async fn sync(&self) -> Result { + let _guard = self.sync_lock.lock().await; info!( event = "cloud_rules.sync_started", section = "cloud_rules", @@ -334,19 +358,13 @@ impl CloudRuleStore { let manifest = self.fetch_manifest().await?; validate_manifest(&manifest)?; let (pending, sets) = self.download_manifest_generation(&manifest).await?; - for (name, bytes) in pending { - write_atomic(&self.cache_dir.join(name), &bytes)?; - } let meta = SyncMeta { last_synced_at: Some(Utc::now()), source: BIFLOW_REPOSITORY.into(), snapshot_revision: Some(manifest.commit), sets, }; - write_atomic( - &self.cache_dir.join(META_FILE), - &serde_json::to_vec_pretty(&meta)?, - )?; + self.publish_generation(&pending, &meta)?; let status = status_from_meta(&meta).unwrap_or(CloudRulesStatus { domain_count: 0, ip_count: 0, @@ -369,6 +387,68 @@ impl CloudRuleStore { Ok(status) } + fn publish_generation( + &self, + pending: &[(&'static str, Vec)], + meta: &SyncMeta, + ) -> Result<(), CloudSyncError> { + let staging = self.cache_dir.join(STAGING_DIR); + if staging.exists() { + fs::remove_dir_all(&staging)?; + } + fs::create_dir_all(&staging)?; + let publish = (|| -> Result<(), CloudSyncError> { + for (name, bytes) in pending { + write_atomic(&staging.join(name), bytes)?; + } + write_atomic(&staging.join(META_FILE), &serde_json::to_vec_pretty(meta)?)?; + for (name, _) in pending { + write_atomic(&self.cache_dir.join(name), &fs::read(staging.join(name))?)?; + } + write_atomic( + &self.cache_dir.join(META_FILE), + &fs::read(staging.join(META_FILE))?, + )?; + Ok(()) + })(); + if let Err(cause) = fs::remove_dir_all(&staging) { + warn!( + event = "cloud_rules.staging_cleanup_failed", + section = "cloud_rules", + initiator = "cloud_rule_store", + cause = %cause, + trace_route = "cloud_rule_store->staging_dir", + "cloud rule staging directory could not be removed" + ); + } + publish + } + + async fn fetch_bytes(&self, url: &str) -> Result, CloudSyncError> { + let mut last_error = CloudSyncError::Fetch("cloud fetch failed".into()); + for attempt in 0..FETCH_ATTEMPTS { + match self.fetcher.fetch(url).await { + Ok(bytes) => return Ok(bytes), + Err(error) => { + last_error = error; + warn!( + event = "cloud_rules.fetch_attempt_failed", + section = "cloud_rules", + initiator = "cloud_rule_store", + cause = %last_error, + attempt = attempt + 1, + attempts = FETCH_ATTEMPTS, + "cloud rule fetch attempt failed" + ); + } + } + if attempt + 1 < FETCH_ATTEMPTS { + tokio::time::sleep(FETCH_BACKOFF.saturating_mul(1 << attempt.min(3))).await; + } + } + Err(last_error) + } + async fn download_manifest_generation( &self, manifest: &RemoteManifest, @@ -399,7 +479,7 @@ impl CloudRuleStore { ))); } let url = snapshot_file_url(&manifest.commit, &rule.file); - let bytes = self.fetcher.fetch(&url).await?; + let bytes = self.fetch_bytes(&url).await?; if bytes.len() > MAX_BYTES { return Err(CloudSyncError::Fetch(format!( "response exceeded {MAX_BYTES} bytes" @@ -431,7 +511,7 @@ impl CloudRuleStore { } async fn fetch_manifest(&self) -> Result { - let bytes = self.fetcher.fetch(manifest_fetch_url()).await?; + let bytes = self.fetch_bytes(manifest_fetch_url()).await?; serde_json::from_slice(&bytes).map_err(CloudSyncError::from) } @@ -826,4 +906,89 @@ mod tests { assert!(!cache.join("iran-domains.txt").is_file()); assert!(!cache.join(META_FILE).is_file()); } + + struct FlakyFetcher { + inner: MapFetcher, + remaining: std::sync::atomic::AtomicU32, + } + + #[async_trait] + impl RuleFetcher for FlakyFetcher { + async fn fetch(&self, url: &str) -> Result, CloudSyncError> { + let previous = self + .remaining + .fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + if previous > 0 { + return Err(CloudSyncError::Fetch("flaky".into())); + } + self.remaining + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.inner.fetch(url).await + } + } + + #[tokio::test] + async fn sync_retries_transient_fetches_and_clears_staging() { + let directory = tempfile::tempdir().expect("tempdir"); + let bundled = directory.path().join("bundled"); + let cache = directory.path().join("cache"); + seed_bundled(&bundled); + fs::create_dir_all(cache.join(STAGING_DIR)).expect("stale staging"); + fs::write(cache.join(STAGING_DIR).join("junk.txt"), b"stale").expect("junk"); + + let commit = "c".repeat(40); + let domain_bytes = domain_payload(1_000); + let network_bytes = cidr_payload(100); + let private_bytes = cidr_payload(8); + let manifest = manifest_for( + &commit, + &[ + ( + "iran-domains.txt", + ProviderKind::Domain, + 1_000, + &sha256_hex(&domain_bytes), + ), + ( + "iran-networks.txt", + ProviderKind::IpCidr, + 100, + &sha256_hex(&network_bytes), + ), + ( + "private.txt", + ProviderKind::IpCidr, + 8, + &sha256_hex(&private_bytes), + ), + ], + ); + let mut responses = HashMap::new(); + responses.insert( + manifest_fetch_url().to_owned(), + Ok(serde_json::to_vec(&manifest).expect("manifest json")), + ); + responses.insert( + snapshot_file_url(&commit, "iran-domains.txt"), + Ok(domain_bytes), + ); + responses.insert( + snapshot_file_url(&commit, "iran-networks.txt"), + Ok(network_bytes), + ); + responses.insert(snapshot_file_url(&commit, "private.txt"), Ok(private_bytes)); + + let store = CloudRuleStore::with_fetcher( + bundled, + cache.clone(), + Arc::new(FlakyFetcher { + inner: MapFetcher { responses }, + remaining: std::sync::atomic::AtomicU32::new(2), + }), + ); + let status = store.sync().await.expect("sync after retries"); + assert_eq!(status.snapshot_revision, Some(commit)); + assert!(!cache.join(STAGING_DIR).exists()); + assert!(cache.join("iran-domains.txt").is_file()); + } } diff --git a/docs/adr/0041-reliable-cloud-rule-sync.md b/docs/adr/0041-reliable-cloud-rule-sync.md new file mode 100644 index 0000000..3039740 --- /dev/null +++ b/docs/adr/0041-reliable-cloud-rule-sync.md @@ -0,0 +1,27 @@ +# ADR 0041: Reliable cloud rule sync + +## Status + +Accepted + +## Context + +`CloudRuleStore::sync` downloaded each file once and wrote it straight into +the cache. A hung or proxied request left the operator on a spinning button, +and a mid-write failure could mix a new file with old metadata. + +## Decision + +- Retry each fetch three times with backoff. The HTTP client tries the + environment proxy first, then a `no_proxy` client. +- Hold a store mutex so two syncs cannot interleave. +- Write the validated generation into `.staging`, then atomically persist + each rule file and `sync-meta.json` last. Remove staging on success or + failure. The previous cache stays the last known good set when publish + never starts. +- The UI ignores a second Update-from-cloud click while `actionPending`. + +## Consequences + +- Transient GitHub or proxy failures no longer fail the first attempt. +- A leftover `.staging` directory cannot poison the next sync. diff --git a/docs/adr/README.md b/docs/adr/README.md index 74038be..f87cf26 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -44,3 +44,4 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th | [0038](./0038-windows-tun-readiness.md) | Windows TUN readiness and clash alignment | Accepted | | [0039](./0039-complete-update-channels.md) | Complete update channels | Accepted | | [0040](./0040-reliable-update-check.md) | Reliable update check | Accepted | +| [0041](./0041-reliable-cloud-rule-sync.md) | Reliable cloud rule sync | Accepted | From 14fb260a3b86a23a1d7abc6eec9266d7d074a9d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 23:39:02 +0000 Subject: [PATCH 04/21] Show a bottom navigation bar on mobile-sized viewports. Advanced mode keeps the sidebar from 768px up and switches to a five-item bottom bar below that width. The bar sits above the status bar so the two never overlap, and there is no hamburger menu. Co-authored-by: Dariush vesal --- apps/desktop/src/App.tsx | 17 +++++-- .../desktop/src/components/BottomNav.test.tsx | 18 +++++++ apps/desktop/src/components/BottomNav.tsx | 48 +++++++++++++++++++ apps/desktop/src/lib/viewport.test.ts | 38 +++++++++++++++ apps/desktop/src/lib/viewport.ts | 26 ++++++++++ docs/adr/0042-responsive-bottom-nav.md | 24 ++++++++++ docs/adr/README.md | 1 + 7 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/components/BottomNav.test.tsx create mode 100644 apps/desktop/src/components/BottomNav.tsx create mode 100644 apps/desktop/src/lib/viewport.test.ts create mode 100644 apps/desktop/src/lib/viewport.ts create mode 100644 docs/adr/0042-responsive-bottom-nav.md diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0d57a81..34d09fa 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -22,7 +22,9 @@ import { AppStatusBar } from "./components/AppStatusBar"; import { Diagnostics } from "./components/Diagnostics"; import { DirectRules } from "./components/DirectRules"; import { Settings } from "./components/Settings"; +import { BottomNav } from "./components/BottomNav"; import { UiModeSwitch } from "./components/UiModeSwitch"; +import { isMobileViewport, subscribeMobileViewport } from "./lib/viewport"; import { readUiMode, type UiMode } from "./lib/uiMode"; import { useAppStore } from "./store/app"; @@ -40,12 +42,15 @@ export function App() { const { i18n, t } = useTranslation(); const store = useAppStore(); const [uiMode, setUiMode] = useState(() => readUiMode()); + const [mobile, setMobile] = useState(isMobileViewport); const [dark, setDark] = useState( () => (localStorage.getItem("biflow-theme") ?? localStorage.getItem("iran-split-theme")) === "dark", ); + useEffect(() => subscribeMobileViewport(setMobile), []); + useEffect(() => { document.documentElement.classList.toggle("dark", dark); localStorage.setItem("biflow-theme", dark ? "dark" : "light"); @@ -111,12 +116,15 @@ export function App() { return (
- {advanced ? ( -
diff --git a/apps/desktop/src/components/BottomNav.test.tsx b/apps/desktop/src/components/BottomNav.test.tsx new file mode 100644 index 0000000..8e9e8dd --- /dev/null +++ b/apps/desktop/src/components/BottomNav.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { useAppStore } from "../store/app"; +import { BottomNav } from "./BottomNav"; + +describe("BottomNav", () => { + it("navigates from the compact bar without a hamburger menu", async () => { + useAppStore.setState({ page: "dashboard" }); + render(); + expect(screen.getByTestId("bottom-nav")).toBeVisible(); + expect( + screen.queryByRole("button", { name: /menu/i }), + ).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "About" })); + expect(useAppStore.getState().page).toBe("about"); + }); +}); diff --git a/apps/desktop/src/components/BottomNav.tsx b/apps/desktop/src/components/BottomNav.tsx new file mode 100644 index 0000000..4dbf70f --- /dev/null +++ b/apps/desktop/src/components/BottomNav.tsx @@ -0,0 +1,48 @@ +import { + Activity, + BookOpen, + Info, + LayoutDashboard, + SettingsIcon, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useAppStore } from "../store/app"; + +const items = [ + { page: "dashboard", icon: LayoutDashboard, labelKey: "dashboard" }, + { page: "rules", icon: BookOpen, labelKey: "rules" }, + { page: "diagnostics", icon: Activity, labelKey: "diagnostics" }, + { page: "settings", icon: SettingsIcon, labelKey: "settings" }, + { page: "about", icon: Info, labelKey: "about" }, +] as const; + +export function BottomNav() { + const { t } = useTranslation(); + const { page: current, setPage } = useAppStore(); + + return ( + + ); +} diff --git a/apps/desktop/src/lib/viewport.test.ts b/apps/desktop/src/lib/viewport.test.ts new file mode 100644 index 0000000..cb0f3ac --- /dev/null +++ b/apps/desktop/src/lib/viewport.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import { isMobileViewport, subscribeMobileViewport } from "./viewport"; + +describe("viewport", () => { + it("treats a missing matchMedia as desktop", () => { + const original = window.matchMedia; + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: undefined, + }); + expect(isMobileViewport()).toBe(false); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: original, + }); + }); + + it("reports the current max-width: 767px media query", () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as typeof window.matchMedia; + expect(isMobileViewport()).toBe(true); + }); + + it("unsubscribes the media listener", () => { + const removeEventListener = vi.fn(); + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener, + }) as unknown as typeof window.matchMedia; + const stop = subscribeMobileViewport(vi.fn()); + stop(); + expect(removeEventListener).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/desktop/src/lib/viewport.ts b/apps/desktop/src/lib/viewport.ts new file mode 100644 index 0000000..c1111bc --- /dev/null +++ b/apps/desktop/src/lib/viewport.ts @@ -0,0 +1,26 @@ +const MOBILE_QUERY = "(max-width: 767px)"; + +export function isMobileViewport(): boolean { + if ( + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) { + return false; + } + return window.matchMedia(MOBILE_QUERY).matches; +} + +export function subscribeMobileViewport( + listener: (mobile: boolean) => void, +): () => void { + if ( + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) { + return () => undefined; + } + const media = window.matchMedia(MOBILE_QUERY); + const onChange = () => listener(media.matches); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); +} diff --git a/docs/adr/0042-responsive-bottom-nav.md b/docs/adr/0042-responsive-bottom-nav.md new file mode 100644 index 0000000..8bc8375 --- /dev/null +++ b/docs/adr/0042-responsive-bottom-nav.md @@ -0,0 +1,24 @@ +# ADR 0042: Responsive bottom navigation + +## Status + +Accepted + +## Context + +The Advanced shell used a 15rem sidebar and a fixed 1120×760 window. Mobile +and tablet viewports need the same five destinations without a hamburger menu, +and the status bar must not sit under the nav. + +## Decision + +- Treat `max-width: 767px` as mobile. Advanced mobile renders a bottom + navigation bar (icons + labels). Desktop/tablet keep the sidebar. +- Stack `main` (scroll), optional bottom nav, then the status bar as flex + siblings so they never overlap. +- Do not introduce a hamburger control. + +## Consequences + +- Playwright and browser checks at 390×844 exercise the bottom bar. +- 768px and above keep the sidebar so tablet/desktop layouts stay familiar. diff --git a/docs/adr/README.md b/docs/adr/README.md index f87cf26..bff5b47 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -45,3 +45,4 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th | [0039](./0039-complete-update-channels.md) | Complete update channels | Accepted | | [0040](./0040-reliable-update-check.md) | Reliable update check | Accepted | | [0041](./0041-reliable-cloud-rule-sync.md) | Reliable cloud rule sync | Accepted | +| [0042](./0042-responsive-bottom-nav.md) | Responsive bottom navigation | Accepted | From 1c71d80f32de8317ff081a70519babcb25da747f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 23:39:21 +0000 Subject: [PATCH 05/21] Make the About page Basic control open the Basic dashboard. Selecting Basic from About now sets the page to dashboard, matching the same control on every other screen instead of leaving the About article up. Co-authored-by: Dariush vesal --- apps/desktop/src/App.test.tsx | 14 ++++++++++++++ apps/desktop/src/App.tsx | 10 +++++++++- docs/adr/0023-basic-mode-persistence.md | 5 +++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 55a2f27..c413c43 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -77,6 +77,20 @@ describe("App", () => { expect(screen.getByRole("button", { name: "Connect" })).toBeVisible(); }); + it("leaves About for the Basic dashboard when Basic is selected", async () => { + render(); + expect( + await screen.findByRole("heading", { name: "Ready when you are" }), + ).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "About" })); + expect(screen.getByRole("heading", { name: "About" })).toBeVisible(); + await userEvent.click(screen.getByRole("radio", { name: "Basic" })); + expect( + screen.queryByRole("heading", { name: "About" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeVisible(); + }); + it("blocks the document context menu", () => { render(); const event = new MouseEvent("contextmenu", { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 34d09fa..7d6bca9 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -171,7 +171,15 @@ export function App() {
- + { + setUiMode(mode); + if (mode === "basic") { + useAppStore.getState().setPage("dashboard"); + } + }} + />
{!advanced && store.page !== "about" && store.snapshot ? ( diff --git a/docs/adr/0023-basic-mode-persistence.md b/docs/adr/0023-basic-mode-persistence.md index 48f0f17..4f8f729 100644 --- a/docs/adr/0023-basic-mode-persistence.md +++ b/docs/adr/0023-basic-mode-persistence.md @@ -16,8 +16,9 @@ pause, resume, and disconnect controls. The preference must survive restarts. every current screen and capability until they opt into Basic. - Render Basic mode as a dedicated minimal dashboard with only the segmented mode control, lifecycle actions, progress/cancel, and concise inline errors. -- Keep About reachable from the tray menu and Advanced sidebar even when Basic - mode hides navigation and the status bar. +- Keep About reachable from the tray menu even when Basic mode hides + navigation. Selecting Basic from About (or any other page) returns to the + Basic dashboard so the control matches every other screen. ## Consequences From f5c9d96951bf2c8ddf083faa089097ff7ca562ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 23:39:36 +0000 Subject: [PATCH 06/21] Enable vertical scrolling on the Dashboard page. The dashboard section now uses overflow-y-auto so tall content scrolls inside the page instead of being clipped by overflow-hidden. Co-authored-by: Dariush vesal --- apps/desktop/src/components/Dashboard.test.tsx | 7 +++++++ apps/desktop/src/components/Dashboard.tsx | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/components/Dashboard.test.tsx b/apps/desktop/src/components/Dashboard.test.tsx index c62170d..7938fa8 100644 --- a/apps/desktop/src/components/Dashboard.test.tsx +++ b/apps/desktop/src/components/Dashboard.test.tsx @@ -226,4 +226,11 @@ describe("Dashboard", () => { await userEvent.click(screen.getByRole("button", { name: "Resume" })); expect(useAppStore.getState().actionPending).toBe(true); }); + + it("scrolls the dashboard section vertically when content overflows", () => { + const { container } = render(); + expect(container.querySelector("section")?.className).toMatch( + /overflow-y-auto/, + ); + }); }); diff --git a/apps/desktop/src/components/Dashboard.tsx b/apps/desktop/src/components/Dashboard.tsx index 4121252..e8f0bf3 100644 --- a/apps/desktop/src/components/Dashboard.tsx +++ b/apps/desktop/src/components/Dashboard.tsx @@ -52,7 +52,7 @@ export function Dashboard({ snapshot }: { snapshot: StackSnapshot }) { return (
From e477c4b8390d608f61590a8c6ea51687777f6f8b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 23:39:46 +0000 Subject: [PATCH 07/21] Keep the status bar sticky at the bottom of the window. The bar is now a shrink-0 sticky footer in both Basic and Advanced mode so page scroll never carries it away or hides it under content. Co-authored-by: Dariush vesal --- apps/desktop/src/App.test.tsx | 2 +- apps/desktop/src/App.tsx | 2 +- apps/desktop/src/components/AppStatusBar.test.tsx | 1 + apps/desktop/src/components/AppStatusBar.tsx | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index c413c43..96cee47 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -73,7 +73,7 @@ describe("App", () => { expect( screen.queryByRole("button", { name: "Direct rules" }), ).not.toBeInTheDocument(); - expect(screen.queryByText("Internet connected")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toBeVisible(); expect(screen.getByRole("button", { name: "Connect" })).toBeVisible(); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7d6bca9..15c5df8 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -201,7 +201,7 @@ export function App() {
{advanced && mobile ? : null} - {advanced ? : null} +
{advanced ? ( diff --git a/apps/desktop/src/components/AppStatusBar.test.tsx b/apps/desktop/src/components/AppStatusBar.test.tsx index 83a3161..fd6773e 100644 --- a/apps/desktop/src/components/AppStatusBar.test.tsx +++ b/apps/desktop/src/components/AppStatusBar.test.tsx @@ -23,6 +23,7 @@ describe("AppStatusBar", () => { expect(screen.getByRole("status")).toHaveTextContent("203.0.113.8"); expect(screen.getByRole("status")).toHaveTextContent("Tehran"); expect(screen.getByRole("status")).toHaveTextContent("🇮🇷"); + expect(screen.getByRole("status").className).toMatch(/sticky/); }); it("creates flags only for valid ISO country codes", () => { diff --git a/apps/desktop/src/components/AppStatusBar.tsx b/apps/desktop/src/components/AppStatusBar.tsx index 7cf74bc..cd7c569 100644 --- a/apps/desktop/src/components/AppStatusBar.tsx +++ b/apps/desktop/src/components/AppStatusBar.tsx @@ -36,7 +36,7 @@ export function AppStatusBar() { return (
{advanced ? ( diff --git a/apps/desktop/src/components/InputContextMenu.test.tsx b/apps/desktop/src/components/InputContextMenu.test.tsx new file mode 100644 index 0000000..5417154 --- /dev/null +++ b/apps/desktop/src/components/InputContextMenu.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { InputContextMenu } from "./InputContextMenu"; + +describe("InputContextMenu", () => { + beforeEach(() => { + Object.assign(navigator, { + clipboard: { + writeText: vi.fn(async () => undefined), + readText: vi.fn(async () => "pasted"), + }, + }); + }); + + it("offers select all, copy, cut, and paste on a text field", async () => { + render( +
+ + +
, + ); + const field = screen.getByLabelText("Host"); + if (!(field instanceof HTMLInputElement)) { + throw new Error("host field is missing"); + } + field.focus(); + field.setSelectionRange(0, 0); + fireEvent.contextMenu(field, { clientX: 12, clientY: 20 }); + const menu = screen.getByTestId("input-context-menu"); + expect(menu).toBeVisible(); + expect(screen.getByRole("menuitem", { name: "Copy" })).toBeDisabled(); + expect(screen.getByRole("menuitem", { name: "Cut" })).toBeDisabled(); + expect(screen.getByRole("menuitem", { name: "Paste" })).toBeEnabled(); + await userEvent.click(screen.getByRole("menuitem", { name: "Select All" })); + expect(field).toHaveProperty("selectionStart", 0); + expect(field).toHaveProperty("selectionEnd", "example.ir".length); + }); + + it("pastes clipboard text into the focused field", async () => { + render( +
+ + +
, + ); + const field = screen.getByLabelText("Host"); + fireEvent.contextMenu(field); + await userEvent.click(screen.getByRole("menuitem", { name: "Paste" })); + expect(field).toHaveValue("pasted"); + }); +}); diff --git a/apps/desktop/src/components/InputContextMenu.tsx b/apps/desktop/src/components/InputContextMenu.tsx new file mode 100644 index 0000000..54e5e02 --- /dev/null +++ b/apps/desktop/src/components/InputContextMenu.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { isEditableTarget, selectionLength } from "../lib/editableTarget"; + +interface MenuState { + x: number; + y: number; + field: HTMLInputElement | HTMLTextAreaElement; +} + +export function InputContextMenu() { + const { t } = useTranslation(); + const [menu, setMenu] = useState(null); + + useEffect(() => { + const onContextMenu = (event: MouseEvent) => { + if (!isEditableTarget(event.target)) { + setMenu(null); + return; + } + event.preventDefault(); + event.target.focus(); + setMenu({ + x: event.clientX, + y: event.clientY, + field: event.target, + }); + }; + const dismiss = () => setMenu(null); + document.addEventListener("contextmenu", onContextMenu); + document.addEventListener("click", dismiss); + window.addEventListener("blur", dismiss); + window.addEventListener("resize", dismiss); + return () => { + document.removeEventListener("contextmenu", onContextMenu); + document.removeEventListener("click", dismiss); + window.removeEventListener("blur", dismiss); + window.removeEventListener("resize", dismiss); + }; + }, []); + + if (!menu) { + return null; + } + + const readonly = menu.field.readOnly || menu.field.disabled; + const hasSelection = selectionLength(menu.field) > 0; + + return ( +
    event.stopPropagation()} + > + { + menu.field.focus(); + menu.field.select(); + }} + /> + { + void copySelection(menu.field); + }} + /> + { + void cutSelection(menu.field); + }} + /> + { + void pasteInto(menu.field); + }} + /> +
+ ); +} + +function MenuItem({ + label, + disabled, + onSelect, +}: { + label: string; + disabled: boolean; + onSelect: () => void; +}) { + return ( +
  • + +
  • + ); +} + +async function copySelection( + field: HTMLInputElement | HTMLTextAreaElement, +): Promise { + const text = field.value.slice( + field.selectionStart ?? 0, + field.selectionEnd ?? 0, + ); + if (text) { + await navigator.clipboard.writeText(text); + } +} + +async function cutSelection( + field: HTMLInputElement | HTMLTextAreaElement, +): Promise { + await copySelection(field); + const start = field.selectionStart ?? 0; + const end = field.selectionEnd ?? 0; + replaceRange(field, start, end, ""); +} + +async function pasteInto( + field: HTMLInputElement | HTMLTextAreaElement, +): Promise { + const text = await navigator.clipboard.readText(); + const start = field.selectionStart ?? field.value.length; + const end = field.selectionEnd ?? field.value.length; + replaceRange(field, start, end, text); +} + +function replaceRange( + field: HTMLInputElement | HTMLTextAreaElement, + start: number, + end: number, + insert: string, +): void { + const next = field.value.slice(0, start) + insert + field.value.slice(end); + field.value = next; + field.dispatchEvent(new Event("input", { bubbles: true })); + const caret = start + insert.length; + field.setSelectionRange(caret, caret); +} diff --git a/apps/desktop/src/i18n/config.ts b/apps/desktop/src/i18n/config.ts index c14ae8b..d6ea9ae 100644 --- a/apps/desktop/src/i18n/config.ts +++ b/apps/desktop/src/i18n/config.ts @@ -39,6 +39,10 @@ const resources = { "Animated traffic leaving this device and splitting between direct and VPN routes", device: "This device", lastUpdated: "Last updated", + contextSelectAll: "Select All", + contextCopy: "Copy", + contextCut: "Cut", + contextPaste: "Paste", currentIp: "Current IP", refreshNetwork: "Refresh connection and IP status", trafficSent: "Sent", @@ -181,6 +185,10 @@ const resources = { "نمای متحرک ترافیک خروجی دستگاه که بین مسیر مستقیم و وی‌پی‌ان تقسیم می‌شود", device: "این دستگاه", lastUpdated: "آخرین بروزرسانی", + contextSelectAll: "انتخاب همه", + contextCopy: "رونوشت", + contextCut: "برش", + contextPaste: "چسباندن", currentIp: "آی‌پی فعلی", refreshNetwork: "تازه‌سازی وضعیت اتصال و آی‌پی", trafficSent: "ارسال", diff --git a/apps/desktop/src/installContextMenuGuard.ts b/apps/desktop/src/installContextMenuGuard.ts index a96150c..88e8ba6 100644 --- a/apps/desktop/src/installContextMenuGuard.ts +++ b/apps/desktop/src/installContextMenuGuard.ts @@ -1,7 +1,12 @@ +import { isEditableTarget } from "./lib/editableTarget"; + export function installContextMenuGuard(): void { document.addEventListener( "contextmenu", (event) => { + if (isEditableTarget(event.target)) { + return; + } event.preventDefault(); }, { capture: true }, diff --git a/apps/desktop/src/lib/editableTarget.test.ts b/apps/desktop/src/lib/editableTarget.test.ts new file mode 100644 index 0000000..1b171db --- /dev/null +++ b/apps/desktop/src/lib/editableTarget.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { isEditableTarget, selectionLength } from "./editableTarget"; + +describe("editableTarget", () => { + it("accepts text and number inputs and textareas", () => { + const text = document.createElement("input"); + text.type = "text"; + const number = document.createElement("input"); + number.type = "number"; + const area = document.createElement("textarea"); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + expect(isEditableTarget(text)).toBe(true); + expect(isEditableTarget(number)).toBe(true); + expect(isEditableTarget(area)).toBe(true); + expect(isEditableTarget(checkbox)).toBe(false); + expect(isEditableTarget(document.body)).toBe(false); + }); + + it("measures a text selection", () => { + const field = document.createElement("input"); + field.value = "abcdef"; + field.setSelectionRange(1, 4); + expect(selectionLength(field)).toBe(3); + }); +}); diff --git a/apps/desktop/src/lib/editableTarget.ts b/apps/desktop/src/lib/editableTarget.ts new file mode 100644 index 0000000..28bc42c --- /dev/null +++ b/apps/desktop/src/lib/editableTarget.ts @@ -0,0 +1,33 @@ +const TEXT_INPUT_TYPES = new Set([ + "text", + "search", + "url", + "tel", + "password", + "number", + "email", +]); + +export function isEditableTarget( + target: EventTarget | null, +): target is HTMLInputElement | HTMLTextAreaElement { + if (target instanceof HTMLTextAreaElement) { + return true; + } + if (target instanceof HTMLInputElement) { + return TEXT_INPUT_TYPES.has(target.type.toLowerCase()); + } + return false; +} + +export function selectionLength( + field: HTMLInputElement | HTMLTextAreaElement, +): number { + if ( + typeof field.selectionStart === "number" && + typeof field.selectionEnd === "number" + ) { + return Math.max(0, field.selectionEnd - field.selectionStart); + } + return 0; +} diff --git a/apps/desktop/src/shell.test.ts b/apps/desktop/src/shell.test.ts index f1a8d95..0d2b018 100644 --- a/apps/desktop/src/shell.test.ts +++ b/apps/desktop/src/shell.test.ts @@ -50,4 +50,18 @@ describe("fixed desktop shell", () => { document.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); }); + + it("leaves text and number inputs free for the custom context menu", () => { + installContextMenuGuard(); + const input = document.createElement("input"); + input.type = "text"; + document.body.append(input); + const event = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + input.remove(); + }); }); diff --git a/docs/adr/0048-input-context-menu.md b/docs/adr/0048-input-context-menu.md new file mode 100644 index 0000000..856bdc7 --- /dev/null +++ b/docs/adr/0048-input-context-menu.md @@ -0,0 +1,24 @@ +# ADR 0048: Input context menu + +## Status + +Accepted + +## Context + +A capture-phase `contextmenu` guard blocked the native menu everywhere, +including text and number fields. Operators could not select, copy, cut, or +paste from a right-click. + +## Decision + +- Keep blocking `contextmenu` on chrome. +- Allow the event on text, number, and textarea fields, then show a custom + menu: Select All, Copy, Cut, Paste. +- Disable Copy/Cut when there is no selection, and disable Cut/Paste on + read-only or disabled fields. + +## Consequences + +- Playwright and Vitest cover the menu on the diagnostics target field. +- Clipboard paste uses the standard `navigator.clipboard` API. diff --git a/docs/adr/README.md b/docs/adr/README.md index 91011f9..b38f7dc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -51,3 +51,4 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th | [0045](./0045-square-connection-glow.md) | Square connection glow | Accepted | | [0046](./0046-persist-window-size.md) | Persist window size | Accepted | | [0047](./0047-three-viewport-layouts.md) | Three representative viewport layouts | Accepted | +| [0048](./0048-input-context-menu.md) | Input context menu | Accepted | diff --git a/e2e/primary-flows.spec.ts b/e2e/primary-flows.spec.ts index 18451e1..e336781 100644 --- a/e2e/primary-flows.spec.ts +++ b/e2e/primary-flows.spec.ts @@ -13,7 +13,11 @@ async function openFresh(page: Page, mode: "basic" | "advanced" = "advanced") { [mode], ); await page.reload(); - await expect(page.getByText("BiFlow")).toBeVisible(); + await expect(page.getByRole("radio", { name: "Advanced" })).toBeVisible(); +} + +function connectButton(page: Page) { + return page.getByRole("button", { name: "Connect", exact: true }); } async function expectNoDocumentOverflow(page: Page) { @@ -66,7 +70,7 @@ test.describe("primary BiFlow flows", () => { page.getByRole("button", { name: "Install", exact: true }), ).toHaveCount(0); - await page.getByRole("button", { name: "Connect" }).click(); + await connectButton(page).click(); await expect( page.getByRole("heading", { name: "Protected split routing is active" }), ).toBeVisible(); @@ -102,7 +106,7 @@ test.describe("primary BiFlow flows", () => { await expect( page.getByRole("button", { name: "Install", exact: true }), ).toHaveCount(2); - await page.getByRole("button", { name: "Connect" }).click(); + await connectButton(page).click(); await expect( page.getByRole("heading", { name: "Protected split routing is active" }), ).toBeVisible(); @@ -208,7 +212,7 @@ test.describe("primary BiFlow flows", () => { await installButtons.first().click(); await expect(installButtons).toHaveCount(0); - await page.getByRole("button", { name: "Connect" }).click(); + await connectButton(page).click(); await expect(shell).toHaveAttribute("data-connection-glow", "active"); await expect(shell).toHaveClass(/connection-glow-active/); @@ -306,13 +310,13 @@ test.describe("primary BiFlow flows", () => { page, }) => { await openFresh(page, "basic"); - await expect(page.getByRole("button", { name: "Connect" })).toBeVisible(); + await expect(connectButton(page)).toBeVisible(); await expect( page.getByRole("button", { name: "Direct rules" }), ).toHaveCount(0); await expectNoDocumentOverflow(page); - await page.getByRole("button", { name: "Connect" }).click(); + await connectButton(page).click(); await expect( page.getByRole("heading", { name: "Protected split routing is active" }), ).toBeVisible(); @@ -332,13 +336,13 @@ test.describe("primary BiFlow flows", () => { }) => { await openFresh(page); await page.getByRole("radio", { name: "Basic" }).click(); - await expect(page.getByRole("button", { name: "Connect" })).toBeVisible(); + await expect(connectButton(page)).toBeVisible(); await expect( page.getByRole("button", { name: "Direct rules" }), ).toHaveCount(0); await expectNoDocumentOverflow(page); - await page.getByRole("button", { name: "Connect" }).click(); + await connectButton(page).click(); await expect( page.getByRole("heading", { name: "Protected split routing is active" }), ).toBeVisible(); @@ -351,7 +355,7 @@ test.describe("primary BiFlow flows", () => { page.getByRole("heading", { name: "Protected split routing is active" }), ).toBeVisible(); await page.getByRole("button", { name: "Disconnect" }).click(); - await expect(page.getByRole("button", { name: "Connect" })).toBeVisible(); + await expect(connectButton(page)).toBeVisible(); await page.getByRole("radio", { name: "Advanced" }).click(); await expect( @@ -362,6 +366,36 @@ test.describe("primary BiFlow flows", () => { ).toBeVisible(); }); + test("offers select all, copy, cut, and paste on text inputs", async ({ + page, + }) => { + await openFresh(page); + await page.getByRole("button", { name: "Diagnostics" }).click(); + const field = page.getByLabel("Test IP or domain"); + await field.fill("example.ir"); + await field.evaluate((node) => { + if (node instanceof HTMLInputElement) { + node.focus(); + node.setSelectionRange(0, 0); + } + }); + await field.click({ button: "right" }); + const menu = page.getByTestId("input-context-menu"); + await expect(menu).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Select All" }), + ).toBeEnabled(); + await expect(menu.getByRole("menuitem", { name: "Copy" })).toBeDisabled(); + await expect(menu.getByRole("menuitem", { name: "Cut" })).toBeDisabled(); + await expect(menu.getByRole("menuitem", { name: "Paste" })).toBeEnabled(); + await menu.getByRole("menuitem", { name: "Select All" }).click(); + await expect(field).toHaveJSProperty("selectionStart", 0); + await expect(field).toHaveJSProperty("selectionEnd", "example.ir".length); + await field.click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Copy" })).toBeEnabled(); + await expect(page.getByRole("menuitem", { name: "Cut" })).toBeEnabled(); + }); + test("blocks the document context menu", async ({ page }) => { await openFresh(page); const prevented = await page.evaluate(() => { diff --git a/package.json b/package.json index 8d7292f..c2d6ffb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iran-split-desktop-workspace", - "version": "3.2.5", + "version": "3.2.6", "private": true, "packageManager": "pnpm@9.0.1", "engines": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d8097ef..1dcef5a 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": "3.2.5", + "version": "3.2.6", "identifier": "app.biflow.desktop", "build": { "beforeDevCommand": "pnpm bundle:check && pnpm --dir apps/desktop dev", diff --git a/version b/version index 5ae69bd..34cde56 100644 --- a/version +++ b/version @@ -1 +1 @@ -3.2.5 +3.2.6 From d46baa80f5f05ca3ceeadb95fe49d1713a486dd6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 00:03:30 +0000 Subject: [PATCH 16/21] Update the tray menu to Connect/Disconnect, Pause/Resume, and Quit. Co-authored-by: Dariush vesal --- AGENTS.md | 1 + Cargo.lock | 22 ++-- Cargo.toml | 2 +- apps/desktop/package.json | 2 +- docs/adr/0049-state-aware-tray-menu.md | 27 +++++ docs/adr/README.md | 1 + package.json | 2 +- scripts/tauri-contract.test.mjs | 6 +- src-tauri/src/lib.rs | 145 ++++++++++--------------- src-tauri/src/tray.rs | 78 +++++++++++++ src-tauri/tauri.conf.json | 2 +- version | 2 +- 12 files changed, 184 insertions(+), 106 deletions(-) create mode 100644 docs/adr/0049-state-aware-tray-menu.md create mode 100644 src-tauri/src/tray.rs diff --git a/AGENTS.md b/AGENTS.md index 9c6c83c..bd1a2a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,3 +164,4 @@ If a required command fails or emits a warning from project code, fix it in the - A Windows Connect that dies at Mihomo readiness with `error sending request for url` is not an internal server error. `CoreError::Platform` maps to `errors.internal` in the UI; map a readiness timeout to `ControllerTimeout`. The controller client must use `no_proxy()` or Hiddify's HTTP proxy intercepts `127.0.0.1:19090`. The helper must not `env_clear()` Windows Mihomo down to PATH-only — restore `SYSTEMROOT` (and spawn with `CREATE_NO_WINDOW`), wait briefly for an immediate exit, and ship `wintun.dll` next to `mihomo.exe`. - A later Windows field log reached `ready: 7` / `rules_loaded: 65734` and then rolled Mihomo back with "process or TUN disappeared". `GET /configs` is still the TUN authority (no adapter enumeration), but `tun.device` on Windows is often `Meta` or empty — treat truthy `tun.enable` as active, retry the post-readiness process/TUN check for 5s, and split the error. Generate Windows YAML like clash-master: `find-process-mode: always`, `ipv6: false`, `auto-redirect: false`, DoH `#VPN`. - An interrupted `cargo test`/`clippy` can leave `corrupt metadata` in `target/debug/deps/*.rmeta`. Delete only the file rustc names (and its sibling `.rlib`) and rebuild that crate. Do not `cargo clean`. +- Workspace Clippy `map_unwrap_or` rejects `option.map(f).unwrap_or(default)`. Use `map_or(default, f)` at the tray setup site and similar lookups. diff --git a/Cargo.lock b/Cargo.lock index 9e026d1..0a83898 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1950,7 +1950,7 @@ dependencies = [ [[package]] name = "iran-split-cli" -version = "3.2.4" +version = "3.2.7" dependencies = [ "async-trait", "chrono", @@ -1967,7 +1967,7 @@ dependencies = [ [[package]] name = "iran-split-config" -version = "3.2.4" +version = "3.2.7" dependencies = [ "hex", "rand 0.9.5", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "iran-split-core" -version = "3.2.4" +version = "3.2.7" dependencies = [ "async-trait", "chrono", @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "iran-split-desktop" -version = "3.2.4" +version = "3.2.7" dependencies = [ "chrono", "dirs 6.0.0", @@ -2029,7 +2029,7 @@ dependencies = [ [[package]] name = "iran-split-helper" -version = "3.2.4" +version = "3.2.7" dependencies = [ "clap", "hex", @@ -2050,7 +2050,7 @@ dependencies = [ [[package]] name = "iran-split-helper-winacl" -version = "3.2.4" +version = "3.2.7" dependencies = [ "tokio", "windows", @@ -2058,7 +2058,7 @@ dependencies = [ [[package]] name = "iran-split-ipc" -version = "3.2.4" +version = "3.2.7" dependencies = [ "bytes", "chrono", @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "iran-split-mihomo" -version = "3.2.4" +version = "3.2.7" dependencies = [ "chrono", "futures-util", @@ -2093,7 +2093,7 @@ dependencies = [ [[package]] name = "iran-split-platform-linux" -version = "3.2.4" +version = "3.2.7" dependencies = [ "async-trait", "chrono", @@ -2115,7 +2115,7 @@ dependencies = [ [[package]] name = "iran-split-platform-win" -version = "3.2.4" +version = "3.2.7" dependencies = [ "async-trait", "chrono", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "iran-split-rules" -version = "3.2.4" +version = "3.2.7" dependencies = [ "async-trait", "chrono", diff --git a/Cargo.toml b/Cargo.toml index eb28801..085e9e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "3.2.6" +version = "3.2.7" 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 084343c..d6ac25d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@iran-split/desktop", - "version": "3.2.6", + "version": "3.2.7", "private": true, "type": "module", "scripts": { diff --git a/docs/adr/0049-state-aware-tray-menu.md b/docs/adr/0049-state-aware-tray-menu.md new file mode 100644 index 0000000..a99a25d --- /dev/null +++ b/docs/adr/0049-state-aware-tray-menu.md @@ -0,0 +1,27 @@ +# ADR 0049: State-aware tray menu + +## Status + +Accepted + +## Context + +The tray menu listed Connect, Pause, Resume, Disconnect, Open, About, Quit UI, +and Disconnect & Quit at once. Operators could pick both sides of a pair, and +the labels did not follow the live stack phase. + +## Decision + +- The tray always shows exactly three actions: Connect or Disconnect, Pause or + Resume, and Quit, with a separator between each item. +- Labels come from `StackPhase`: a live stack (`running`, `degraded`, `paused`) + shows Disconnect; only `paused` shows Resume; every other phase shows Connect + and Pause. +- Rebuild the menu on every stack snapshot so the items change immediately. +- Identify the icon as `main` so the snapshot watcher can replace its menu. + +## Consequences + +- Open and About remain available from the window; left-click still shows the + main window. +- Quit exits the UI without disconnecting, matching the previous Quit UI item. diff --git a/docs/adr/README.md b/docs/adr/README.md index b38f7dc..8d27de9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,3 +52,4 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th | [0046](./0046-persist-window-size.md) | Persist window size | Accepted | | [0047](./0047-three-viewport-layouts.md) | Three representative viewport layouts | Accepted | | [0048](./0048-input-context-menu.md) | Input context menu | Accepted | +| [0049](./0049-state-aware-tray-menu.md) | State-aware tray menu | Accepted | diff --git a/package.json b/package.json index c2d6ffb..5c3a8b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iran-split-desktop-workspace", - "version": "3.2.6", + "version": "3.2.7", "private": true, "packageManager": "pnpm@9.0.1", "engines": { diff --git a/scripts/tauri-contract.test.mjs b/scripts/tauri-contract.test.mjs index a7146d2..57d08b1 100644 --- a/scripts/tauri-contract.test.mjs +++ b/scripts/tauri-contract.test.mjs @@ -76,8 +76,12 @@ describe("Tauri frontend contract", () => { assert.match(rust, /default_window_icon\(\)/); assert.match( rust, - /TrayIconBuilder::new\(\)[\s\S]*?\.icon\(icon\)[\s\S]*?\.menu\(&menu\)/, + /TrayIconBuilder::with_id\("main"\)[\s\S]*?\.icon\(icon\)[\s\S]*?\.menu\(&menu\)/, ); + assert.match(rust, /PredefinedMenuItem::separator/); + assert.match(rust, /apply_tray_menu/); + const tray = readFileSync(join(root, "src-tauri/src/tray.rs"), "utf8"); + assert.match(tray, /fn labels_for/); }); it("disables WebKitGTK DMA-BUF rendering before the Linux webview starts", () => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b234a8c..615b17c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod helper_install; mod hiddify_reset; mod network; mod traffic; +mod tray; mod version; mod window_state; @@ -27,7 +28,7 @@ use std::{ time::Duration, }; use tauri::{ - menu::{Menu, MenuEvent, MenuItem}, + menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}, AppHandle, Emitter, LogicalSize, Manager, Runtime, Size, Window, WindowEvent, }; @@ -2026,53 +2027,8 @@ fn disconnect_from_tray(app: &AppHandle) { }); } -fn disconnect_and_quit_from_tray(app: &AppHandle) { - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let result = - diagnostics::trace_action("lifecycle", "tray_menu", "disconnect_and_quit", async { - let services = services(&app)?; - services - .engine - .stop_stack() - .await - .map_err(|error| error.to_string())?; - services - .engine - .wait_for_phase(StackPhase::Stopped, Duration::from_secs(25)) - .await - .map_err(|error| error.to_string())?; - Ok::<(), String>(()) - }) - .await; - if let Err(cause) = result { - error!( - event = "shutdown.disconnect_failed", - section = "lifecycle", - initiator = "tray_menu", - cause, - trace_route = "tray_menu->stop_stack->application_exit", - "disconnect before quit failed" - ); - } - diagnostics::flush(); - app.exit(0); - }); -} - fn handle_tray_menu(app: &AppHandle, event: &MenuEvent) { match event.id.as_ref() { - "open" => { - info!( - event = "window.open_requested", - section = "window", - initiator = "tray_menu", - cause = "open_selected", - trace_route = "tray_menu->show_main", - "main window open requested" - ); - show_main(app); - } "connect" => connect_from_tray(app), "pause" => pause_from_tray(app), "resume" => resume_from_tray(app), @@ -2088,28 +2044,6 @@ fn handle_tray_menu(app: &AppHandle, event: &MenuEvent) { ); app.exit(0); } - "disconnect_quit" => disconnect_and_quit_from_tray(app), - "about" => { - info!( - event = "window.about_requested", - section = "window", - initiator = "tray_menu", - cause = "about_selected", - trace_route = "tray_menu->show_main->app-navigate", - "about page requested from tray" - ); - show_main(app); - if let Err(cause) = app.emit("app-navigate", "about") { - warn!( - event = "navigation.emit_failed", - section = "window", - initiator = "tray_menu", - cause = %cause, - trace_route = "tray_menu->emit(app-navigate)", - "about navigation event could not be emitted" - ); - } - } unknown => warn!( event = "tray.unknown_action", section = "tray", @@ -2122,38 +2056,69 @@ fn handle_tray_menu(app: &AppHandle, event: &MenuEvent) { } } -fn setup_tray(app: &tauri::App) -> tauri::Result<()> { - let connect = MenuItem::with_id(app, "connect", "Connect", true, None::<&str>)?; - let pause = MenuItem::with_id(app, "pause", "Pause", true, None::<&str>)?; - let resume = MenuItem::with_id(app, "resume", "Resume", true, None::<&str>)?; - let disconnect = MenuItem::with_id(app, "disconnect", "Disconnect", true, None::<&str>)?; - let open = MenuItem::with_id(app, "open", "Open", true, None::<&str>)?; - let about = MenuItem::with_id(app, "about", "About", true, None::<&str>)?; - let quit = MenuItem::with_id(app, "quit", "Quit UI", true, None::<&str>)?; - let disconnect_quit = MenuItem::with_id( +fn build_tray_menu(app: &AppHandle, phase: StackPhase) -> tauri::Result> { + let labels = tray::labels_for(phase); + let connection = MenuItem::with_id( app, - "disconnect_quit", - "Disconnect & Quit", + labels.connection_id, + labels.connection_label, true, None::<&str>, )?; - let menu = Menu::with_items( + let pause = MenuItem::with_id(app, labels.pause_id, labels.pause_label, true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let first_separator = PredefinedMenuItem::separator(app)?; + let second_separator = PredefinedMenuItem::separator(app)?; + Menu::with_items( app, &[ - &connect, + &connection, + &first_separator, &pause, - &resume, - &disconnect, - &open, - &about, + &second_separator, &quit, - &disconnect_quit, ], - )?; + ) +} + +fn apply_tray_menu(app: &AppHandle, phase: StackPhase) { + let Ok(menu) = build_tray_menu(app, phase) else { + warn!( + event = "tray.menu_build_failed", + section = "tray", + initiator = "apply_tray_menu", + cause = "menu_construction", + trace_route = "snapshot_watcher->build_tray_menu", + "tray menu could not be rebuilt" + ); + return; + }; + let Some(icon) = app.tray_by_id("main") else { + return; + }; + if let Err(cause) = icon.set_menu(Some(menu)) { + warn!( + event = "tray.menu_update_failed", + section = "tray", + initiator = "apply_tray_menu", + cause = %cause, + trace_route = "snapshot_watcher->tray.set_menu", + "tray menu could not be replaced" + ); + } +} + +fn setup_tray(app: &tauri::App) -> tauri::Result<()> { + let phase = app + .try_state::() + .map_or(StackPhase::Stopped, |services| { + services.engine.snapshot().phase + }); + let menu = build_tray_menu(app.handle(), phase)?; let icon = app.default_window_icon().cloned().ok_or_else(|| { tauri::Error::from(std::io::Error::other("default window icon is missing")) })?; - TrayIconBuilder::new() + TrayIconBuilder::with_id("main") .icon(icon) .menu(&menu) .show_menu_on_left_click(false) @@ -2223,7 +2188,8 @@ fn setup_application(app: &mut tauri::App) -> Result<(), Box Result<(), Box TrayLabels { + let connected = matches!( + phase, + StackPhase::Running | StackPhase::Degraded | StackPhase::Paused + ); + let paused = matches!(phase, StackPhase::Paused); + TrayLabels { + connection_id: if connected { "disconnect" } else { "connect" }, + connection_label: if connected { "Disconnect" } else { "Connect" }, + pause_id: if paused { "resume" } else { "pause" }, + pause_label: if paused { "Resume" } else { "Pause" }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stopped_shows_connect_and_pause() { + let labels = labels_for(StackPhase::Stopped); + assert_eq!(labels.connection_id, "connect"); + assert_eq!(labels.connection_label, "Connect"); + assert_eq!(labels.pause_id, "pause"); + assert_eq!(labels.pause_label, "Pause"); + } + + #[test] + fn running_shows_disconnect_and_pause() { + let labels = labels_for(StackPhase::Running); + assert_eq!(labels.connection_id, "disconnect"); + assert_eq!(labels.pause_id, "pause"); + } + + #[test] + fn paused_shows_disconnect_and_resume() { + let labels = labels_for(StackPhase::Paused); + assert_eq!(labels.connection_id, "disconnect"); + assert_eq!(labels.connection_label, "Disconnect"); + assert_eq!(labels.pause_id, "resume"); + assert_eq!(labels.pause_label, "Resume"); + } + + #[test] + fn never_emits_both_options_from_the_same_pair() { + for phase in [ + StackPhase::Uninitialized, + StackPhase::Stopped, + StackPhase::StartingHiddify, + StackPhase::PreparingRuntime, + StackPhase::ValidatingConfig, + StackPhase::StartingCore, + StackPhase::CheckingReadiness, + StackPhase::Running, + StackPhase::Paused, + StackPhase::Degraded, + StackPhase::Stopping, + StackPhase::Recovering, + StackPhase::Error, + ] { + let labels = labels_for(phase); + assert!((labels.connection_id == "connect") ^ (labels.connection_id == "disconnect")); + assert!((labels.pause_id == "pause") ^ (labels.pause_id == "resume")); + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1dcef5a..97fb230 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": "3.2.6", + "version": "3.2.7", "identifier": "app.biflow.desktop", "build": { "beforeDevCommand": "pnpm bundle:check && pnpm --dir apps/desktop dev", diff --git a/version b/version index 34cde56..406ebcb 100644 --- a/version +++ b/version @@ -1 +1 @@ -3.2.6 +3.2.7 From d4de982a12626d593b9bcd27f8bcdb9c22609cf2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 00:15:54 +0000 Subject: [PATCH 17/21] Prevent concurrent Connect, Disconnect, Pause, and Resume operations. Co-authored-by: Dariush vesal --- AGENTS.md | 1 + Cargo.lock | 22 +- Cargo.toml | 2 +- apps/desktop/package.json | 2 +- apps/desktop/src/api/mock.test.ts | 14 +- apps/desktop/src/api/mock.ts | 57 ++- apps/desktop/src/api/models.ts | 8 + .../desktop/src/components/BasicDashboard.tsx | 10 +- .../desktop/src/components/Dashboard.test.tsx | 39 +++ apps/desktop/src/components/Dashboard.tsx | 10 +- apps/desktop/src/i18n/config.ts | 4 + apps/desktop/src/lib/lifecycle.test.ts | 34 ++ apps/desktop/src/lib/lifecycle.ts | 35 ++ apps/desktop/src/store/app.test.ts | 59 ++++ apps/desktop/src/store/app.ts | 23 +- crates/iran-split-core/src/lib.rs | 331 +++++++++++++++--- docs/adr/0050-connection-operation-lock.md | 32 ++ docs/adr/README.md | 1 + docs/considering/desktop-sixteen-task-plan.md | 4 + e2e/primary-flows.spec.ts | 23 ++ package.json | 2 +- scripts/tauri-contract.test.mjs | 2 + src-tauri/src/lib.rs | 54 ++- src-tauri/src/tray.rs | 16 +- src-tauri/tauri.conf.json | 2 +- version | 2 +- 26 files changed, 701 insertions(+), 88 deletions(-) create mode 100644 apps/desktop/src/lib/lifecycle.test.ts create mode 100644 apps/desktop/src/lib/lifecycle.ts create mode 100644 docs/adr/0050-connection-operation-lock.md diff --git a/AGENTS.md b/AGENTS.md index bd1a2a0..80b2275 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,4 +164,5 @@ If a required command fails or emits a warning from project code, fix it in the - A Windows Connect that dies at Mihomo readiness with `error sending request for url` is not an internal server error. `CoreError::Platform` maps to `errors.internal` in the UI; map a readiness timeout to `ControllerTimeout`. The controller client must use `no_proxy()` or Hiddify's HTTP proxy intercepts `127.0.0.1:19090`. The helper must not `env_clear()` Windows Mihomo down to PATH-only — restore `SYSTEMROOT` (and spawn with `CREATE_NO_WINDOW`), wait briefly for an immediate exit, and ship `wintun.dll` next to `mihomo.exe`. - A later Windows field log reached `ready: 7` / `rules_loaded: 65734` and then rolled Mihomo back with "process or TUN disappeared". `GET /configs` is still the TUN authority (no adapter enumeration), but `tun.device` on Windows is often `Meta` or empty — treat truthy `tun.enable` as active, retry the post-readiness process/TUN check for 5s, and split the error. Generate Windows YAML like clash-master: `find-process-mode: always`, `ipv6: false`, `auto-redirect: false`, DoH `#VPN`. - An interrupted `cargo test`/`clippy` can leave `corrupt metadata` in `target/debug/deps/*.rmeta`. Delete only the file rustc names (and its sibling `.rlib`) and rebuild that crate. Do not `cargo clean`. +- `pause_stack` used to treat `Stopped` as already complete without checking the lifecycle lock, so Pause succeeded while Connect was still reserved. Reject any other `busy` kind before the idempotent phase shortcuts. - Workspace Clippy `map_unwrap_or` rejects `option.map(f).unwrap_or(default)`. Use `map_or(default, f)` at the tray setup site and similar lookups. diff --git a/Cargo.lock b/Cargo.lock index 0a83898..23a358b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1950,7 +1950,7 @@ dependencies = [ [[package]] name = "iran-split-cli" -version = "3.2.7" +version = "3.2.8" dependencies = [ "async-trait", "chrono", @@ -1967,7 +1967,7 @@ dependencies = [ [[package]] name = "iran-split-config" -version = "3.2.7" +version = "3.2.8" dependencies = [ "hex", "rand 0.9.5", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "iran-split-core" -version = "3.2.7" +version = "3.2.8" dependencies = [ "async-trait", "chrono", @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "iran-split-desktop" -version = "3.2.7" +version = "3.2.8" dependencies = [ "chrono", "dirs 6.0.0", @@ -2029,7 +2029,7 @@ dependencies = [ [[package]] name = "iran-split-helper" -version = "3.2.7" +version = "3.2.8" dependencies = [ "clap", "hex", @@ -2050,7 +2050,7 @@ dependencies = [ [[package]] name = "iran-split-helper-winacl" -version = "3.2.7" +version = "3.2.8" dependencies = [ "tokio", "windows", @@ -2058,7 +2058,7 @@ dependencies = [ [[package]] name = "iran-split-ipc" -version = "3.2.7" +version = "3.2.8" dependencies = [ "bytes", "chrono", @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "iran-split-mihomo" -version = "3.2.7" +version = "3.2.8" dependencies = [ "chrono", "futures-util", @@ -2093,7 +2093,7 @@ dependencies = [ [[package]] name = "iran-split-platform-linux" -version = "3.2.7" +version = "3.2.8" dependencies = [ "async-trait", "chrono", @@ -2115,7 +2115,7 @@ dependencies = [ [[package]] name = "iran-split-platform-win" -version = "3.2.7" +version = "3.2.8" dependencies = [ "async-trait", "chrono", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "iran-split-rules" -version = "3.2.7" +version = "3.2.8" dependencies = [ "async-trait", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 085e9e2..7b97203 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "3.2.7" +version = "3.2.8" 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 d6ac25d..6496dd9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@iran-split/desktop", - "version": "3.2.7", + "version": "3.2.8", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src/api/mock.test.ts b/apps/desktop/src/api/mock.test.ts index 631d4a3..666569f 100644 --- a/apps/desktop/src/api/mock.test.ts +++ b/apps/desktop/src/api/mock.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { APP_VERSION } from "../version"; import { mockApi, resetMockState } from "./mock"; @@ -78,6 +78,18 @@ describe("mock transport", () => { expect(phases.at(-1)).toBe("restarting"); }); + it("rejects a second connection operation while one is running", async () => { + const first = mockApi.start(); + await expect(mockApi.stop()).rejects.toThrow(/already in progress/); + await expect(mockApi.pause()).rejects.toThrow(/already in progress/); + await first; + await vi.waitFor(async () => { + const snapshot = await mockApi.getSnapshot(); + expect(snapshot.phase).toBe("running"); + expect(snapshot.busy).toBeNull(); + }); + }); + it("fails mock install when signature verification is forced to fail", async () => { sessionStorage.setItem("biflow-mock-update-available", "1"); sessionStorage.setItem("biflow-mock-update-fail", "1"); diff --git a/apps/desktop/src/api/mock.ts b/apps/desktop/src/api/mock.ts index 8ee65af..ebd7f36 100644 --- a/apps/desktop/src/api/mock.ts +++ b/apps/desktop/src/api/mock.ts @@ -17,6 +17,7 @@ import type { NetworkStatus, OperationAccepted, RouteTestResult, + LifecycleBusy, StackPhase, StackSnapshot, UpdateProgress, @@ -42,6 +43,7 @@ function initialSnapshot(): StackSnapshot { return { revision: 1, phase: "stopped", + busy: null, operation_id: null, helper: helperMissing ? { @@ -378,17 +380,35 @@ async function simulateInstallProgress(version: string) { }); } -function emit(phase: StackPhase, operationId: string | null) { +let lifecycleBusy: LifecycleBusy | null = null; + +function emit( + phase: StackPhase, + operationId: string | null, + busy: LifecycleBusy | null = lifecycleBusy, +) { snapshot = { ...snapshot, revision: snapshot.revision + 1, phase, + busy, operation_id: operationId, updated_at: now(), }; for (const listener of listeners) listener(structuredClone(snapshot)); } +function assertIdle(): void { + if (lifecycleBusy) { + throw new Error("operation is already in progress"); + } +} + +function begin(busy: LifecycleBusy): void { + assertIdle(); + lifecycleBusy = busy; +} + function operation(): OperationAccepted { return { operation_id: crypto.randomUUID(), already_complete: false }; } @@ -419,7 +439,8 @@ async function runStart(accepted: OperationAccepted) { }, exit_ip: "203.0.113.42", }; - emit("running", null); + lifecycleBusy = null; + emit("running", null, null); logs.push({ timestamp: now(), level: "info", @@ -458,19 +479,28 @@ export const mockApi = { return { ...trafficTotals }; }, async start(): Promise { + if (lifecycleBusy && lifecycleBusy !== "connecting") { + throw new Error("operation is already in progress"); + } if (snapshot.phase === "running") { return { operation_id: crypto.randomUUID(), already_complete: true }; } + begin("connecting"); const accepted = operation(); + emit(snapshot.phase, accepted.operation_id, "connecting"); void runStart(accepted); return accepted; }, async stop(): Promise { + if (lifecycleBusy && lifecycleBusy !== "disconnecting") { + throw new Error("operation is already in progress"); + } if (snapshot.phase === "stopped") { return { operation_id: crypto.randomUUID(), already_complete: true }; } + begin("disconnecting"); const accepted = operation(); - emit("stopping", accepted.operation_id); + emit("stopping", accepted.operation_id, "disconnecting"); window.setTimeout(() => { snapshot = { ...snapshot, @@ -481,19 +511,24 @@ export const mockApi = { providers: { ready: 0, total: 0, rules_loaded: 0, last_refresh: null }, exit_ip: null, }; - emit("stopped", null); + lifecycleBusy = null; + emit("stopped", null, null); }, 350); return accepted; }, async pause(): Promise { + if (lifecycleBusy && lifecycleBusy !== "pausing") { + throw new Error("operation is already in progress"); + } if (snapshot.phase === "paused") { return { operation_id: crypto.randomUUID(), already_complete: true }; } if (snapshot.phase !== "running" && snapshot.phase !== "degraded") { return { operation_id: crypto.randomUUID(), already_complete: true }; } + begin("pausing"); const accepted = operation(); - emit("stopping", accepted.operation_id); + emit("stopping", accepted.operation_id, "pausing"); window.setTimeout(() => { snapshot = { ...snapshot, @@ -504,24 +539,31 @@ export const mockApi = { providers: { ready: 0, total: 0, rules_loaded: 0, last_refresh: null }, exit_ip: null, }; - emit("paused", null); + lifecycleBusy = null; + emit("paused", null, null); }, 350); return accepted; }, async resume(): Promise { + if (lifecycleBusy && lifecycleBusy !== "resuming") { + throw new Error("operation is already in progress"); + } if (snapshot.phase === "running") { return { operation_id: crypto.randomUUID(), already_complete: true }; } if (snapshot.phase !== "paused") { return { operation_id: crypto.randomUUID(), already_complete: true }; } + begin("resuming"); const accepted = operation(); + emit(snapshot.phase, accepted.operation_id, "resuming"); void runStart(accepted); return accepted; }, async cancel(operationId: string) { if (snapshot.operation_id === operationId) { - emit("stopped", null); + lifecycleBusy = null; + emit("stopped", null, null); return true; } return false; @@ -807,6 +849,7 @@ export function resetMockState() { } catch { // jsdom and Playwright always provide web storage. } + lifecycleBusy = null; snapshot = initialSnapshot(); trafficTotals = { sent: 1_048_576, received: 2_097_152 }; settings = initialSettings(); diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts index de7789b..c212f31 100644 --- a/apps/desktop/src/api/models.ts +++ b/apps/desktop/src/api/models.ts @@ -1,3 +1,10 @@ +export type LifecycleBusy = + | "connecting" + | "disconnecting" + | "pausing" + | "resuming" + | "reconciling"; + export type StackPhase = | "uninitialized" | "stopped" @@ -55,6 +62,7 @@ export interface AppError { export interface StackSnapshot { revision: number; phase: StackPhase; + busy?: LifecycleBusy | null; operation_id: string | null; helper: ComponentStatus; hiddify: ComponentStatus; diff --git a/apps/desktop/src/components/BasicDashboard.tsx b/apps/desktop/src/components/BasicDashboard.tsx index ad06492..27dc342 100644 --- a/apps/desktop/src/components/BasicDashboard.tsx +++ b/apps/desktop/src/components/BasicDashboard.tsx @@ -1,6 +1,7 @@ import { LoaderCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { StackPhase, StackSnapshot } from "../api/models"; +import { controlsLocked } from "../lib/lifecycle"; import { useAppStore } from "../store/app"; const progressPhases: StackPhase[] = [ @@ -24,6 +25,7 @@ export function BasicDashboard({ snapshot }: { snapshot: StackSnapshot }) { } = useAppStore(); const active = snapshot.phase === "running" || snapshot.phase === "degraded"; const paused = snapshot.phase === "paused"; + const locked = controlsLocked(snapshot, actionPending); const operating = progressPhases.includes(snapshot.phase) || snapshot.phase === "stopping"; const progressIndex = progressPhases.indexOf(snapshot.phase); @@ -106,7 +108,7 @@ export function BasicDashboard({ snapshot }: { snapshot: StackSnapshot }) { {active ? ( + ) : null} {store.installGuide ? ( - + ) : null} - + + {t("close")} @@ -342,15 +350,15 @@ function ThemeButton({ dark: boolean; setDark: (dark: boolean) => void; }) { + const label = dark ? "Use light theme" : "Use dark theme"; return ( - + ); } @@ -362,17 +370,16 @@ function LanguageButton({ change: (language: string) => void; }) { return ( - + ); } diff --git a/apps/desktop/src/components/About.tsx b/apps/desktop/src/components/About.tsx index 2614bac..f8c7ffb 100644 --- a/apps/desktop/src/components/About.tsx +++ b/apps/desktop/src/components/About.tsx @@ -1,4 +1,11 @@ -import { Info, RefreshCw } from "lucide-react"; +import { + Download, + ExternalLink, + Info, + RefreshCw, + RotateCw, +} from "lucide-react"; +import { AppButton, BUTTON_ICON_PX } from "./AppButton"; import { useTranslation } from "react-i18next"; import { APP_VERSION } from "../version"; import { useAppStore } from "../store/app"; @@ -55,13 +62,13 @@ export function About() { {t("aboutRepositoryLabel")}
    - +
    @@ -121,23 +128,23 @@ export function About() { : t("updateCheck")} {showInstall ? ( - + ) : null} {showRetry ? ( - + ) : null} diff --git a/apps/desktop/src/components/AppButton.test.tsx b/apps/desktop/src/components/AppButton.test.tsx new file mode 100644 index 0000000..e249ae5 --- /dev/null +++ b/apps/desktop/src/components/AppButton.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { Power } from "lucide-react"; +import { describe, expect, it } from "vitest"; +import { AppButton, BUTTON_ICON_PX, IconOnlyButton } from "./AppButton"; + +describe("AppButton", () => { + it("renders a consistent icon beside the label", () => { + render( + }> + Connect + , + ); + expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled(); + expect(screen.getByTestId("button-icon")).toBeInTheDocument(); + expect(BUTTON_ICON_PX).toBe(18); + }); + + it("gives icon-only buttons a tooltip and accessible name", () => { + render( + + sun + , + ); + const button = screen.getByRole("button", { name: "Use light theme" }); + expect(button).toHaveAttribute("title", "Use light theme"); + }); +}); diff --git a/apps/desktop/src/components/AppButton.tsx b/apps/desktop/src/components/AppButton.tsx new file mode 100644 index 0000000..ca46f85 --- /dev/null +++ b/apps/desktop/src/components/AppButton.tsx @@ -0,0 +1,40 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +export const BUTTON_ICON_PX = 18; + +const withIcon = "inline-flex items-center justify-center gap-2 text-start"; + +export function AppButton({ + icon, + children, + className = "", + type = "button", + ...props +}: ButtonHTMLAttributes & { icon?: ReactNode }) { + return ( + + ); +} + +export function IconOnlyButton({ + label, + children, + className = "", + type = "button", + ...props +}: ButtonHTMLAttributes & { label: string }) { + return ( + + ); +} diff --git a/apps/desktop/src/components/BasicDashboard.tsx b/apps/desktop/src/components/BasicDashboard.tsx index 27dc342..4214221 100644 --- a/apps/desktop/src/components/BasicDashboard.tsx +++ b/apps/desktop/src/components/BasicDashboard.tsx @@ -1,8 +1,17 @@ -import { LoaderCircle } from "lucide-react"; +import { + Download, + LoaderCircle, + Pause, + Play, + Power, + PowerOff, + X, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import type { StackPhase, StackSnapshot } from "../api/models"; import { controlsLocked } from "../lib/lifecycle"; import { useAppStore } from "../store/app"; +import { AppButton, BUTTON_ICON_PX } from "./AppButton"; const progressPhases: StackPhase[] = [ "starting_hiddify", @@ -84,68 +93,77 @@ export function BasicDashboard({ snapshot }: { snapshot: StackSnapshot }) { >

    {showError}

    {missing ? ( - + ) : null} ) : null}
    {operating && snapshot.operation_id ? ( - + ) : null} {active ? ( - + ) : null} {paused ? ( - + ) : null} {!paused ? ( - + ) : ( - + )}
    diff --git a/apps/desktop/src/components/Dashboard.test.tsx b/apps/desktop/src/components/Dashboard.test.tsx index 5deb80c..708d1f6 100644 --- a/apps/desktop/src/components/Dashboard.test.tsx +++ b/apps/desktop/src/components/Dashboard.test.tsx @@ -49,7 +49,9 @@ describe("Dashboard", () => { useAppStore.setState({ snapshot: stopped, actionPending: false }); render(); expect(screen.getAllByText("stopped")).toHaveLength(5); - await userEvent.click(screen.getByRole("button", { name: "Connect" })); + const connect = screen.getByRole("button", { name: "Connect" }); + expect(connect.querySelector("svg")).not.toBeNull(); + await userEvent.click(connect); expect(useAppStore.getState().actionPending).toBe(true); }); diff --git a/apps/desktop/src/components/Dashboard.tsx b/apps/desktop/src/components/Dashboard.tsx index 941a564..bbc4ed9 100644 --- a/apps/desktop/src/components/Dashboard.tsx +++ b/apps/desktop/src/components/Dashboard.tsx @@ -7,12 +7,18 @@ import { Globe2, LoaderCircle, Network, + Pause, + Play, + Power, + PowerOff, ShieldCheck, + X, } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { ComponentStatus, StackPhase, StackSnapshot } from "../api/models"; import { controlsLocked } from "../lib/lifecycle"; import { useAppStore } from "../store/app"; +import { AppButton, BUTTON_ICON_PX } from "./AppButton"; import { StatusPill } from "./StatusPill"; const progressPhases: StackPhase[] = [ @@ -75,52 +81,58 @@ export function Dashboard({ snapshot }: { snapshot: StackSnapshot }) {
    {operating && snapshot.operation_id ? ( - + ) : null} {active ? ( - + ) : null} {paused ? ( - + ) : null} {!paused ? ( - + ) : ( - + )}
    diff --git a/apps/desktop/src/components/DirectRules.tsx b/apps/desktop/src/components/DirectRules.tsx index f6deef3..28a0e5f 100644 --- a/apps/desktop/src/components/DirectRules.tsx +++ b/apps/desktop/src/components/DirectRules.tsx @@ -192,6 +192,11 @@ export function DirectRules({ rules }: { rules: DirectRulesDocument }) { ) } className="rounded-lg border border-ink/15 p-2 text-muted hover:text-brand" + title={ + outbound === "vpn" + ? t("moveToDirect", { target: rule.target.value }) + : t("moveToVpn", { target: rule.target.value }) + } aria-label={ outbound === "vpn" ? t("moveToDirect", { target: rule.target.value }) @@ -209,6 +214,7 @@ export function DirectRules({ rules }: { rules: DirectRulesDocument }) { disabled={testing} onClick={() => void test(rule.target.value)} className="rounded-lg border border-ink/15 p-2 text-muted hover:text-brand" + title={`Test route for ${rule.target.value}`} aria-label={`Test route for ${rule.target.value}`} > @@ -218,6 +224,7 @@ export function DirectRules({ rules }: { rules: DirectRulesDocument }) { disabled={actionPending} onClick={() => void removeRule(rule.target.value)} className="rounded-lg border border-ink/15 p-2 text-muted hover:text-danger" + title={`Remove ${rule.target.value}`} aria-label={`Remove ${rule.target.value}`} > diff --git a/apps/desktop/src/components/InputContextMenu.tsx b/apps/desktop/src/components/InputContextMenu.tsx index 54e5e02..5315915 100644 --- a/apps/desktop/src/components/InputContextMenu.tsx +++ b/apps/desktop/src/components/InputContextMenu.tsx @@ -1,6 +1,8 @@ -import { useEffect, useState } from "react"; +import { ClipboardPaste, Copy, Scissors, TextSelect } from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { isEditableTarget, selectionLength } from "../lib/editableTarget"; +import { BUTTON_ICON_PX } from "./AppButton"; interface MenuState { x: number; @@ -55,6 +57,7 @@ export function InputContextMenu() { onClick={(event) => event.stopPropagation()} > } label={t("contextSelectAll")} disabled={menu.field.disabled} onSelect={() => { @@ -63,6 +66,7 @@ export function InputContextMenu() { }} /> } label={t("contextCopy")} disabled={!hasSelection} onSelect={() => { @@ -70,6 +74,7 @@ export function InputContextMenu() { }} /> } label={t("contextCut")} disabled={readonly || !hasSelection} onSelect={() => { @@ -77,6 +82,7 @@ export function InputContextMenu() { }} /> } label={t("contextPaste")} disabled={readonly} onSelect={() => { @@ -88,10 +94,12 @@ export function InputContextMenu() { } function MenuItem({ + icon, label, disabled, onSelect, }: { + icon: ReactNode; label: string; disabled: boolean; onSelect: () => void; @@ -102,9 +110,10 @@ function MenuItem({ type="button" role="menuitem" disabled={disabled} - className="block w-full px-3 py-1.5 text-start disabled:text-muted" + className="inline-flex w-full items-center gap-2 px-3 py-1.5 text-start disabled:text-muted" onClick={onSelect} > + {icon} {label} diff --git a/apps/desktop/src/components/UiModeSwitch.tsx b/apps/desktop/src/components/UiModeSwitch.tsx index 78f883d..0189a94 100644 --- a/apps/desktop/src/components/UiModeSwitch.tsx +++ b/apps/desktop/src/components/UiModeSwitch.tsx @@ -1,6 +1,9 @@ +import { SlidersHorizontal, Sparkles } from "lucide-react"; +import type { KeyboardEvent, ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { UiMode } from "../lib/uiMode"; import { writeUiMode } from "../lib/uiMode"; +import { BUTTON_ICON_PX } from "./AppButton"; export function UiModeSwitch({ mode, @@ -18,7 +21,7 @@ export function UiModeSwitch({ onChange(next); } - function onKeyDown(event: React.KeyboardEvent) { + function onKeyDown(event: KeyboardEvent) { const forward = rtl ? event.key === "ArrowLeft" : event.key === "ArrowRight"; @@ -50,11 +53,13 @@ export function UiModeSwitch({ }} /> } label={t("uiModeBasic")} checked={mode === "basic"} onSelect={() => select("basic")} /> } label={t("uiModeAdvanced")} checked={mode === "advanced"} onSelect={() => select("advanced")} @@ -64,10 +69,12 @@ export function UiModeSwitch({ } function ModeOption({ + icon, label, checked, onSelect, }: { + icon: ReactNode; label: string; checked: boolean; onSelect: () => void; @@ -79,10 +86,11 @@ function ModeOption({ aria-checked={checked} tabIndex={checked ? 0 : -1} onClick={onSelect} - className={`relative z-10 rounded-lg px-4 py-2.5 text-sm font-semibold transition-colors ${ + className={`relative z-10 inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold transition-colors ${ checked ? "text-brand" : "text-muted hover:text-ink" }`} > + {icon} {label} ); diff --git a/docs/adr/0051-button-icons.md b/docs/adr/0051-button-icons.md new file mode 100644 index 0000000..47dd4bf --- /dev/null +++ b/docs/adr/0051-button-icons.md @@ -0,0 +1,24 @@ +# ADR 0051: Icons on every button + +## Status + +Accepted + +## Context + +Lifecycle and several dialog buttons were text-only, while nav, diagnostics, +and install actions already used Lucide. Icon-only chrome (theme, language, +rule pins) had accessible names but no tooltip. + +## Decision + +- Use Lucide for every button, with a shared 18px icon size and `gap-2` + alignment via `AppButton`. +- Icon-only controls use `IconOnlyButton` so `aria-label` and `title` stay in + sync. +- Connection actions use Power / PowerOff / Pause / Play / X so the later + in-button progress work can keep the same glyphs. + +## Consequences + +- Accessible names stay on the visible label; decorative SVGs are `aria-hidden`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 10c43d4..997e753 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,3 +54,4 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th | [0048](./0048-input-context-menu.md) | Input context menu | Accepted | | [0049](./0049-state-aware-tray-menu.md) | State-aware tray menu | Accepted | | [0050](./0050-connection-operation-lock.md) | Connection operation lock | Accepted | +| [0051](./0051-button-icons.md) | Icons on every button | Accepted | diff --git a/package.json b/package.json index 8dbd696..a974db5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iran-split-desktop-workspace", - "version": "3.2.8", + "version": "3.2.9", "private": true, "packageManager": "pnpm@9.0.1", "engines": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 509d554..66dd292 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": "3.2.8", + "version": "3.2.9", "identifier": "app.biflow.desktop", "build": { "beforeDevCommand": "pnpm bundle:check && pnpm --dir apps/desktop dev", diff --git a/version b/version index f092941..e650c01 100644 --- a/version +++ b/version @@ -1 +1 @@ -3.2.8 +3.2.9 From 5040994f38ee81da09a846b7dd24d389ebfe82c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 00:22:57 +0000 Subject: [PATCH 19/21] Show real connection stages as progress inside the action buttons. Co-authored-by: Dariush vesal --- AGENTS.md | 4 + Cargo.lock | 22 +- Cargo.toml | 2 +- apps/desktop/package.json | 2 +- apps/desktop/src/api/mock.test.ts | 17 ++ apps/desktop/src/api/mock.ts | 50 +++-- apps/desktop/src/api/models.ts | 13 ++ .../src/components/BasicDashboard.test.tsx | 44 ++++ .../desktop/src/components/BasicDashboard.tsx | 133 ++++-------- .../ConnectionActionButton.test.tsx | 75 +++++++ .../src/components/ConnectionActionButton.tsx | 68 ++++++ .../desktop/src/components/Dashboard.test.tsx | 16 +- apps/desktop/src/components/Dashboard.tsx | 116 ++++------ apps/desktop/src/i18n/config.ts | 30 +++ apps/desktop/src/index.css | 37 ++++ .../src/lib/connectionProgress.test.ts | 122 +++++++++++ apps/desktop/src/lib/connectionProgress.ts | 200 ++++++++++++++++++ apps/desktop/src/lib/lifecycle.test.ts | 8 +- apps/desktop/src/lib/lifecycle.ts | 12 ++ apps/desktop/src/shell.test.ts | 10 + crates/iran-split-core/src/lib.rs | 138 +++++++++++- .../adr/0052-in-button-connection-progress.md | 30 +++ docs/adr/README.md | 1 + docs/considering/desktop-sixteen-task-plan.md | 2 +- e2e/primary-flows.spec.ts | 9 + e2e/responsive.spec.ts | 30 +++ package.json | 2 +- src-tauri/tauri.conf.json | 2 +- version | 2 +- 29 files changed, 980 insertions(+), 217 deletions(-) create mode 100644 apps/desktop/src/components/BasicDashboard.test.tsx create mode 100644 apps/desktop/src/components/ConnectionActionButton.test.tsx create mode 100644 apps/desktop/src/components/ConnectionActionButton.tsx create mode 100644 apps/desktop/src/lib/connectionProgress.test.ts create mode 100644 apps/desktop/src/lib/connectionProgress.ts create mode 100644 docs/adr/0052-in-button-connection-progress.md diff --git a/AGENTS.md b/AGENTS.md index 80b2275..d5be5ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,5 +164,9 @@ If a required command fails or emits a warning from project code, fix it in the - A Windows Connect that dies at Mihomo readiness with `error sending request for url` is not an internal server error. `CoreError::Platform` maps to `errors.internal` in the UI; map a readiness timeout to `ControllerTimeout`. The controller client must use `no_proxy()` or Hiddify's HTTP proxy intercepts `127.0.0.1:19090`. The helper must not `env_clear()` Windows Mihomo down to PATH-only — restore `SYSTEMROOT` (and spawn with `CREATE_NO_WINDOW`), wait briefly for an immediate exit, and ship `wintun.dll` next to `mihomo.exe`. - A later Windows field log reached `ready: 7` / `rules_loaded: 65734` and then rolled Mihomo back with "process or TUN disappeared". `GET /configs` is still the TUN authority (no adapter enumeration), but `tun.device` on Windows is often `Meta` or empty — treat truthy `tun.enable` as active, retry the post-readiness process/TUN check for 5s, and split the error. Generate Windows YAML like clash-master: `find-process-mode: always`, `ipv6: false`, `auto-redirect: false`, DoH `#VPN`. - An interrupted `cargo test`/`clippy` can leave `corrupt metadata` in `target/debug/deps/*.rmeta`. Delete only the file rustc names (and its sibling `.rlib`) and rebuild that crate. Do not `cargo clean`. +- A `tokio::sync::watch` subscriber misses intermediate `operation_stage` + values when several `update()` calls run in one worker poll. Yield after + each announced milestone, and collect stages with `tokio::join!` against + `receiver.changed()` so the waiter is armed before the operation is queued. - `pause_stack` used to treat `Stopped` as already complete without checking the lifecycle lock, so Pause succeeded while Connect was still reserved. Reject any other `busy` kind before the idempotent phase shortcuts. - Workspace Clippy `map_unwrap_or` rejects `option.map(f).unwrap_or(default)`. Use `map_or(default, f)` at the tray setup site and similar lookups. diff --git a/Cargo.lock b/Cargo.lock index 23a358b..0398cbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1950,7 +1950,7 @@ dependencies = [ [[package]] name = "iran-split-cli" -version = "3.2.8" +version = "3.3.0" dependencies = [ "async-trait", "chrono", @@ -1967,7 +1967,7 @@ dependencies = [ [[package]] name = "iran-split-config" -version = "3.2.8" +version = "3.3.0" dependencies = [ "hex", "rand 0.9.5", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "iran-split-core" -version = "3.2.8" +version = "3.3.0" dependencies = [ "async-trait", "chrono", @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "iran-split-desktop" -version = "3.2.8" +version = "3.3.0" dependencies = [ "chrono", "dirs 6.0.0", @@ -2029,7 +2029,7 @@ dependencies = [ [[package]] name = "iran-split-helper" -version = "3.2.8" +version = "3.3.0" dependencies = [ "clap", "hex", @@ -2050,7 +2050,7 @@ dependencies = [ [[package]] name = "iran-split-helper-winacl" -version = "3.2.8" +version = "3.3.0" dependencies = [ "tokio", "windows", @@ -2058,7 +2058,7 @@ dependencies = [ [[package]] name = "iran-split-ipc" -version = "3.2.8" +version = "3.3.0" dependencies = [ "bytes", "chrono", @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "iran-split-mihomo" -version = "3.2.8" +version = "3.3.0" dependencies = [ "chrono", "futures-util", @@ -2093,7 +2093,7 @@ dependencies = [ [[package]] name = "iran-split-platform-linux" -version = "3.2.8" +version = "3.3.0" dependencies = [ "async-trait", "chrono", @@ -2115,7 +2115,7 @@ dependencies = [ [[package]] name = "iran-split-platform-win" -version = "3.2.8" +version = "3.3.0" dependencies = [ "async-trait", "chrono", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "iran-split-rules" -version = "3.2.8" +version = "3.3.0" dependencies = [ "async-trait", "chrono", diff --git a/Cargo.toml b/Cargo.toml index a9bdd82..f627f08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "3.2.9" +version = "3.3.0" 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 3727ce0..9a4db69 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@iran-split/desktop", - "version": "3.2.9", + "version": "3.3.0", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src/api/mock.test.ts b/apps/desktop/src/api/mock.test.ts index 666569f..832c4e8 100644 --- a/apps/desktop/src/api/mock.test.ts +++ b/apps/desktop/src/api/mock.test.ts @@ -78,6 +78,23 @@ describe("mock transport", () => { expect(phases.at(-1)).toBe("restarting"); }); + it("publishes real start stages on the snapshot", async () => { + const stages: Array = []; + const unsubscribe = mockApi.subscribe((snapshot) => { + stages.push(snapshot.operation_stage); + }); + await mockApi.start(); + await vi.waitFor(async () => { + const snapshot = await mockApi.getSnapshot(); + expect(snapshot.phase).toBe("running"); + }); + unsubscribe(); + expect(stages).toContain("preparing"); + expect(stages).toContain("starting_hiddify"); + expect(stages).toContain("starting_core"); + expect(stages).toContain("checking_readiness"); + }); + it("rejects a second connection operation while one is running", async () => { const first = mockApi.start(); await expect(mockApi.stop()).rejects.toThrow(/already in progress/); diff --git a/apps/desktop/src/api/mock.ts b/apps/desktop/src/api/mock.ts index ebd7f36..9aaa3d4 100644 --- a/apps/desktop/src/api/mock.ts +++ b/apps/desktop/src/api/mock.ts @@ -18,6 +18,7 @@ import type { OperationAccepted, RouteTestResult, LifecycleBusy, + OperationStage, StackPhase, StackSnapshot, UpdateProgress, @@ -44,6 +45,7 @@ function initialSnapshot(): StackSnapshot { revision: 1, phase: "stopped", busy: null, + operation_stage: null, operation_id: null, helper: helperMissing ? { @@ -386,12 +388,14 @@ function emit( phase: StackPhase, operationId: string | null, busy: LifecycleBusy | null = lifecycleBusy, + operationStage: OperationStage | null = null, ) { snapshot = { ...snapshot, revision: snapshot.revision + 1, phase, busy, + operation_stage: operationStage, operation_id: operationId, updated_at: now(), }; @@ -414,15 +418,15 @@ function operation(): OperationAccepted { } async function runStart(accepted: OperationAccepted) { - const phases: StackPhase[] = [ - "starting_hiddify", - "preparing_runtime", - "validating_config", - "starting_core", - "checking_readiness", + const phases: Array<[StackPhase, OperationStage]> = [ + ["starting_hiddify", "starting_hiddify"], + ["preparing_runtime", "preparing_runtime"], + ["validating_config", "validating_config"], + ["starting_core", "starting_core"], + ["checking_readiness", "checking_readiness"], ]; - for (const phase of phases) { - emit(phase, accepted.operation_id); + for (const [phase, stage] of phases) { + emit(phase, accepted.operation_id, lifecycleBusy, stage); await new Promise((resolve) => setTimeout(resolve, 180)); } snapshot = { @@ -440,7 +444,7 @@ async function runStart(accepted: OperationAccepted) { exit_ip: "203.0.113.42", }; lifecycleBusy = null; - emit("running", null, null); + emit("running", null, null, null); logs.push({ timestamp: now(), level: "info", @@ -487,7 +491,7 @@ export const mockApi = { } begin("connecting"); const accepted = operation(); - emit(snapshot.phase, accepted.operation_id, "connecting"); + emit(snapshot.phase, accepted.operation_id, "connecting", "preparing"); void runStart(accepted); return accepted; }, @@ -500,7 +504,18 @@ export const mockApi = { } begin("disconnecting"); const accepted = operation(); - emit("stopping", accepted.operation_id, "disconnecting"); + emit("stopping", accepted.operation_id, "disconnecting", "stopping_core"); + window.setTimeout(() => { + emit( + "stopping", + accepted.operation_id, + "disconnecting", + "stopping_proxy", + ); + }, 120); + window.setTimeout(() => { + emit("stopping", accepted.operation_id, "disconnecting", "cleaning_up"); + }, 220); window.setTimeout(() => { snapshot = { ...snapshot, @@ -512,7 +527,7 @@ export const mockApi = { exit_ip: null, }; lifecycleBusy = null; - emit("stopped", null, null); + emit("stopped", null, null, null); }, 350); return accepted; }, @@ -528,7 +543,10 @@ export const mockApi = { } begin("pausing"); const accepted = operation(); - emit("stopping", accepted.operation_id, "pausing"); + emit("stopping", accepted.operation_id, "pausing", "stopping_core"); + window.setTimeout(() => { + emit("stopping", accepted.operation_id, "pausing", "cleaning_up"); + }, 160); window.setTimeout(() => { snapshot = { ...snapshot, @@ -540,7 +558,7 @@ export const mockApi = { exit_ip: null, }; lifecycleBusy = null; - emit("paused", null, null); + emit("paused", null, null, null); }, 350); return accepted; }, @@ -556,14 +574,14 @@ export const mockApi = { } begin("resuming"); const accepted = operation(); - emit(snapshot.phase, accepted.operation_id, "resuming"); + emit(snapshot.phase, accepted.operation_id, "resuming", "preparing"); void runStart(accepted); return accepted; }, async cancel(operationId: string) { if (snapshot.operation_id === operationId) { lifecycleBusy = null; - emit("stopped", null, null); + emit("stopped", null, null, null); return true; } return false; diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts index c212f31..29b66c7 100644 --- a/apps/desktop/src/api/models.ts +++ b/apps/desktop/src/api/models.ts @@ -5,6 +5,18 @@ export type LifecycleBusy = | "resuming" | "reconciling"; +export type OperationStage = + | "preparing" + | "starting_hiddify" + | "preparing_runtime" + | "validating_config" + | "starting_core" + | "checking_readiness" + | "stopping_core" + | "stopping_proxy" + | "cleaning_up" + | "recovering"; + export type StackPhase = | "uninitialized" | "stopped" @@ -63,6 +75,7 @@ export interface StackSnapshot { revision: number; phase: StackPhase; busy?: LifecycleBusy | null; + operation_stage?: OperationStage | null; operation_id: string | null; helper: ComponentStatus; hiddify: ComponentStatus; diff --git a/apps/desktop/src/components/BasicDashboard.test.tsx b/apps/desktop/src/components/BasicDashboard.test.tsx new file mode 100644 index 0000000..854c7cb --- /dev/null +++ b/apps/desktop/src/components/BasicDashboard.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { StackSnapshot } from "../api/models"; +import { BasicDashboard } from "./BasicDashboard"; + +const now = new Date().toISOString(); +const stopped: StackSnapshot = { + revision: 1, + phase: "stopped", + busy: null, + operation_stage: null, + operation_id: null, + helper: { phase: "running", message: null, since: now }, + hiddify: { phase: "stopped", message: null, since: now }, + mihomo: { phase: "stopped", message: null, since: now }, + tun: { phase: "stopped", message: null, since: now }, + dns: { phase: "stopped", message: null, since: now }, + providers: { ready: 0, total: 0, rules_loaded: 0, last_refresh: null }, + exit_ip: null, + backend: "external_hiddify", + last_error: null, + updated_at: now, +}; + +describe("BasicDashboard", () => { + it("puts Connect progress on the button instead of a status card", () => { + render( + , + ); + const connect = screen.getByRole("button", { name: "Start Hiddify" }); + expect(connect).toBeDisabled(); + expect(connect).toHaveAttribute("data-progress", "25"); + expect(screen.queryByText("%")).toBeNull(); + expect(screen.queryByRole("status")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/components/BasicDashboard.tsx b/apps/desktop/src/components/BasicDashboard.tsx index 4214221..431e5be 100644 --- a/apps/desktop/src/components/BasicDashboard.tsx +++ b/apps/desktop/src/components/BasicDashboard.tsx @@ -1,25 +1,10 @@ -import { - Download, - LoaderCircle, - Pause, - Play, - Power, - PowerOff, - X, -} from "lucide-react"; +import { Download, Pause, Play, Power, PowerOff, X } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { StackPhase, StackSnapshot } from "../api/models"; -import { controlsLocked } from "../lib/lifecycle"; +import type { StackSnapshot } from "../api/models"; +import { controlsLocked, isOperating } from "../lib/lifecycle"; import { useAppStore } from "../store/app"; import { AppButton, BUTTON_ICON_PX } from "./AppButton"; - -const progressPhases: StackPhase[] = [ - "starting_hiddify", - "preparing_runtime", - "validating_config", - "starting_core", - "checking_readiness", -]; +import { ConnectionActionButton } from "./ConnectionActionButton"; export function BasicDashboard({ snapshot }: { snapshot: StackSnapshot }) { const { t } = useTranslation(); @@ -31,13 +16,12 @@ export function BasicDashboard({ snapshot }: { snapshot: StackSnapshot }) { cancel, error, installDependency, + installingId, } = useAppStore(); const active = snapshot.phase === "running" || snapshot.phase === "degraded"; const paused = snapshot.phase === "paused"; const locked = controlsLocked(snapshot, actionPending); - const operating = - progressPhases.includes(snapshot.phase) || snapshot.phase === "stopping"; - const progressIndex = progressPhases.indexOf(snapshot.phase); + const operating = isOperating(snapshot); const missing = snapshot.last_error?.remediation === "install_dependency"; const missingId = snapshot.last_error?.code === "MIHOMO_NOT_FOUND" ? "mihomo" : "hiddify"; @@ -60,32 +44,6 @@ export function BasicDashboard({ snapshot }: { snapshot: StackSnapshot }) {

    {t("basicModeHelp")}

    - {operating ? ( -
    -
    - {snapshot.phase.replaceAll("_", " ")} - - {Math.max( - 10, - ((progressIndex + 1) / progressPhases.length) * 100, - ).toFixed(0)} - % - -
    -
    -
    -
    -
    - ) : null} - {showError ? (
    ) : null} -
    +
    {operating && snapshot.operation_id ? ( } onClick={() => void cancel()} - className="rounded-xl border border-ink/15 bg-surface px-4 py-3 font-semibold" + className="rounded-2xl border border-ink/15 bg-surface px-5 py-3.5 font-semibold" > {t("cancel")} ) : null} {active ? ( - } + void pauseConnection()} - className="rounded-xl border border-ink/15 bg-surface px-4 py-3 font-semibold disabled:opacity-50" - > - {t("pause")} - + icon={} + variant="secondary" + /> ) : null} {paused ? ( - } + void resumeConnection()} - className="min-w-36 rounded-xl bg-brand px-5 py-3 font-semibold text-white disabled:opacity-50" - > - {t("resume")} - + icon={} + variant="primary" + /> ) : null} - {!paused ? ( - - ) : active ? ( - - ) : ( - - ) - } - disabled={locked} - onClick={() => void toggleConnection()} - className="min-w-36 rounded-xl bg-brand px-5 py-3 font-semibold text-white disabled:opacity-50" - > - {active ? t("disconnect") : t("connect")} - - ) : ( - } - disabled={locked} - onClick={() => void toggleConnection()} - className="rounded-xl border border-ink/15 bg-surface px-4 py-3 font-semibold disabled:opacity-50" - > - {t("disconnect")} - - )} + void toggleConnection()} + icon={ + active || paused ? ( + + ) : ( + + ) + } + variant={paused ? "secondary" : "primary"} + />
    ); diff --git a/apps/desktop/src/components/ConnectionActionButton.test.tsx b/apps/desktop/src/components/ConnectionActionButton.test.tsx new file mode 100644 index 0000000..5552316 --- /dev/null +++ b/apps/desktop/src/components/ConnectionActionButton.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from "@testing-library/react"; +import { Power } from "lucide-react"; +import { describe, expect, it } from "vitest"; +import type { StackSnapshot } from "../api/models"; +import { BUTTON_ICON_PX } from "./AppButton"; +import { ConnectionActionButton } from "./ConnectionActionButton"; + +const now = new Date().toISOString(); +const stopped: StackSnapshot = { + revision: 1, + phase: "stopped", + busy: null, + operation_stage: null, + operation_id: null, + helper: { phase: "running", message: null, since: now }, + hiddify: { phase: "stopped", message: null, since: now }, + mihomo: { phase: "stopped", message: null, since: now }, + tun: { phase: "stopped", message: null, since: now }, + dns: { phase: "stopped", message: null, since: now }, + providers: { ready: 0, total: 0, rules_loaded: 0, last_refresh: null }, + exit_ip: null, + backend: "external_hiddify", + last_error: null, + updated_at: now, +}; + +describe("ConnectionActionButton", () => { + it("renders the idle label and an empty fill", () => { + render( + undefined} + icon={} + variant="primary" + />, + ); + const button = screen.getByRole("button", { name: "Connect" }); + expect(button).toHaveAttribute("data-progress", "0"); + expect(button).toHaveAttribute("data-processing", "false"); + expect(button.querySelector(".connection-action-label")?.className).toMatch( + /break-words/, + ); + expect( + button.querySelector(".connection-action-label")?.className, + ).not.toMatch(/truncate|whitespace-nowrap/); + }); + + it("shows the current stage and fill while processing", () => { + render( + undefined} + icon={} + variant="primary" + />, + ); + const button = screen.getByRole("button", { name: "Start Mihomo" }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("data-progress", "70"); + expect(button).toHaveAttribute("aria-busy", "true"); + expect(button.className).toMatch(/connection-action-processing/); + const fill = button.querySelector(".connection-action-fill"); + expect(fill).toHaveStyle({ width: "70%" }); + }); +}); diff --git a/apps/desktop/src/components/ConnectionActionButton.tsx b/apps/desktop/src/components/ConnectionActionButton.tsx new file mode 100644 index 0000000..d1de8a0 --- /dev/null +++ b/apps/desktop/src/components/ConnectionActionButton.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { StackSnapshot } from "../api/models"; +import { + connectionButtonProgress, + type ConnectionAction, +} from "../lib/connectionProgress"; + +export function ConnectionActionButton({ + action, + snapshot, + installingId, + actionPending, + disabled, + onClick, + icon, + variant, +}: { + action: ConnectionAction; + snapshot: StackSnapshot; + installingId?: string | null; + actionPending?: boolean; + disabled: boolean; + onClick: () => void; + icon: ReactNode; + variant: "primary" | "secondary"; +}) { + const { t } = useTranslation(); + const progress = connectionButtonProgress( + snapshot, + action, + installingId, + actionPending, + ); + const label = t(progress.labelKey); + + return ( + + ); +} diff --git a/apps/desktop/src/components/Dashboard.test.tsx b/apps/desktop/src/components/Dashboard.test.tsx index 708d1f6..1e27224 100644 --- a/apps/desktop/src/components/Dashboard.test.tsx +++ b/apps/desktop/src/components/Dashboard.test.tsx @@ -66,10 +66,13 @@ describe("Dashboard", () => { }} />, ); - expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled(); + const connecting = screen.getByRole("button", { name: "Start Hiddify" }); + expect(connecting).toBeDisabled(); + expect(connecting).toHaveAttribute("data-progress", "25"); expect( screen.getByRole("button", { name: "Cancel operation" }), ).toBeEnabled(); + expect(screen.queryByText("%")).toBeNull(); const running = { phase: "running" as const, @@ -90,16 +93,18 @@ describe("Dashboard", () => { }} />, ); - expect(screen.getByRole("button", { name: "Pause" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Stop Mihomo" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Disconnect" })).toBeDisabled(); }); - it("exposes cancellation during an operation", () => { + it("exposes cancellation and in-button progress during an operation", () => { render( , @@ -107,7 +112,10 @@ describe("Dashboard", () => { expect( screen.getByRole("button", { name: "Cancel operation" }), ).toBeEnabled(); - expect(screen.getByRole("status")).toHaveTextContent("starting core"); + const connect = screen.getByRole("button", { name: "Start Mihomo" }); + expect(connect).toBeDisabled(); + expect(connect).toHaveAttribute("data-progress", "70"); + expect(screen.queryByRole("status")).toBeNull(); }); it("shows install actions when Hiddify and Mihomo are missing", async () => { diff --git a/apps/desktop/src/components/Dashboard.tsx b/apps/desktop/src/components/Dashboard.tsx index bbc4ed9..722cb0b 100644 --- a/apps/desktop/src/components/Dashboard.tsx +++ b/apps/desktop/src/components/Dashboard.tsx @@ -15,20 +15,13 @@ import { X, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { ComponentStatus, StackPhase, StackSnapshot } from "../api/models"; -import { controlsLocked } from "../lib/lifecycle"; +import type { ComponentStatus, StackSnapshot } from "../api/models"; +import { controlsLocked, isOperating } from "../lib/lifecycle"; import { useAppStore } from "../store/app"; import { AppButton, BUTTON_ICON_PX } from "./AppButton"; +import { ConnectionActionButton } from "./ConnectionActionButton"; import { StatusPill } from "./StatusPill"; -const progressPhases: StackPhase[] = [ - "starting_hiddify", - "preparing_runtime", - "validating_config", - "starting_core", - "checking_readiness", -]; - export function Dashboard({ snapshot }: { snapshot: StackSnapshot }) { const { t } = useTranslation(); const { @@ -46,9 +39,7 @@ export function Dashboard({ snapshot }: { snapshot: StackSnapshot }) { const active = snapshot.phase === "running" || snapshot.phase === "degraded"; const paused = snapshot.phase === "paused"; const locked = controlsLocked(snapshot, actionPending); - const operating = - progressPhases.includes(snapshot.phase) || snapshot.phase === "stopping"; - const progressIndex = progressPhases.indexOf(snapshot.phase); + const operating = isOperating(snapshot); const needsAttention = [ snapshot.helper, snapshot.hiddify, @@ -79,90 +70,59 @@ export function Dashboard({ snapshot }: { snapshot: StackSnapshot }) {

    {t("routingSummary")}

    -
    +
    {operating && snapshot.operation_id ? ( } onClick={() => void cancel()} - className="rounded-xl border border-ink/15 bg-surface px-4 py-3 font-semibold" + className="rounded-2xl border border-ink/15 bg-surface px-5 py-3.5 font-semibold" > {t("cancel")} ) : null} {active ? ( - } + void pauseConnection()} - className="rounded-xl border border-ink/15 bg-surface px-4 py-3 font-semibold" - > - {t("pause")} - + icon={} + variant="secondary" + /> ) : null} {paused ? ( - } + void resumeConnection()} - className="min-w-36 rounded-xl bg-brand px-5 py-3 font-semibold text-white shadow-lg shadow-brand/20 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-55" - > - {t("resume")} - + icon={} + variant="primary" + /> ) : null} - {!paused ? ( - - ) : ( - - ) - } - disabled={locked} - onClick={() => void toggleConnection()} - className="min-w-36 rounded-xl bg-brand px-5 py-3 font-semibold text-white shadow-lg shadow-brand/20 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-55" - > - {active ? t("disconnect") : t("connect")} - - ) : ( - } - disabled={locked} - onClick={() => void toggleConnection()} - className="rounded-xl border border-ink/15 bg-surface px-4 py-3 font-semibold" - > - {t("disconnect")} - - )} + void toggleConnection()} + icon={ + active || paused ? ( + + ) : ( + + ) + } + variant={paused ? "secondary" : "primary"} + />
    - {operating ? ( -
    -
    - {snapshot.phase.replaceAll("_", " ")} - - {Math.max( - 10, - ((progressIndex + 1) / progressPhases.length) * 100, - ).toFixed(0)} - % - -
    -
    -
    -
    -
    - ) : null} -
    } diff --git a/apps/desktop/src/i18n/config.ts b/apps/desktop/src/i18n/config.ts index 40269f8..ed5d9e5 100644 --- a/apps/desktop/src/i18n/config.ts +++ b/apps/desktop/src/i18n/config.ts @@ -15,6 +15,21 @@ const resources = { pause: "Pause", resume: "Resume", cancel: "Cancel operation", + stages: { + preparing: "Preparing", + startHiddify: "Start Hiddify", + prepareRuntime: "Prepare runtime", + validateConfig: "Validate config", + startMihomo: "Start Mihomo", + checkReadiness: "Check readiness", + stopMihomo: "Stop Mihomo", + stopHiddify: "Stop Hiddify", + cleaningUp: "Cleaning up", + recovering: "Recovering", + installHelper: "Install helper", + installHiddify: "Install Hiddify", + installMihomo: "Install Mihomo", + }, direct: "DIRECT", vpn: "VPN", status: "Status", @@ -163,6 +178,21 @@ const resources = { pause: "توقف موقت", resume: "ادامه", cancel: "لغو عملیات", + stages: { + preparing: "آماده‌سازی", + startHiddify: "شروع هیدیفای", + prepareRuntime: "آماده‌سازی اجرا", + validateConfig: "اعتبارسنجی", + startMihomo: "شروع میهومو", + checkReadiness: "بررسی آمادگی", + stopMihomo: "توقف میهومو", + stopHiddify: "توقف هیدیفای", + cleaningUp: "پاک‌سازی", + recovering: "بازیابی", + installHelper: "نصب کمکی", + installHiddify: "نصب هیدیفای", + installMihomo: "نصب میهومو", + }, direct: "مستقیم", vpn: "وی‌پی‌ان", status: "وضعیت", diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index fc38722..5d4ec07 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -199,3 +199,40 @@ select:focus-visible, opacity: 0.9; } } + +.connection-action { + max-width: 100%; +} + +.connection-action-fill-clip { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: inherit; + pointer-events: none; +} + +.connection-action-fill { + display: block; + height: 100%; + width: 0; + transition: width 280ms ease-out; +} + +.connection-action-primary .connection-action-fill { + background: rgb(255 255 255 / 0.28); +} + +.connection-action-secondary .connection-action-fill { + background: rgb(var(--brand) / 0.28); +} + +.connection-action-processing.connection-action-primary { + background-color: rgb(var(--brand) / 0.62); +} + +@media (prefers-reduced-motion: reduce) { + .connection-action-fill { + transition: none; + } +} diff --git a/apps/desktop/src/lib/connectionProgress.test.ts b/apps/desktop/src/lib/connectionProgress.test.ts new file mode 100644 index 0000000..492b327 --- /dev/null +++ b/apps/desktop/src/lib/connectionProgress.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { StackSnapshot } from "../api/models"; +import { + connectionButtonProgress, + resolveOperationStage, +} from "./connectionProgress"; + +const now = new Date().toISOString(); + +const base = (overrides: Partial = {}): StackSnapshot => ({ + revision: 1, + phase: "stopped", + busy: null, + operation_stage: null, + operation_id: null, + helper: { phase: "running", message: null, since: now }, + hiddify: { phase: "stopped", message: null, since: now }, + mihomo: { phase: "stopped", message: null, since: now }, + tun: { phase: "stopped", message: null, since: now }, + dns: { phase: "stopped", message: null, since: now }, + providers: { ready: 0, total: 0, rules_loaded: 0, last_refresh: null }, + exit_ip: null, + backend: "external_hiddify", + last_error: null, + updated_at: now, + ...overrides, +}); + +describe("connectionButtonProgress", () => { + it("keeps idle labels until a matching operation starts", () => { + const snapshot = base(); + expect(connectionButtonProgress(snapshot, "connect")).toEqual({ + labelKey: "connect", + percent: 0, + processing: false, + }); + expect(connectionButtonProgress(snapshot, "disconnect").processing).toBe( + false, + ); + }); + + it("follows Connect stages from backend milestones", () => { + const start = base({ + busy: "connecting", + operation_stage: "starting_hiddify", + phase: "starting_hiddify", + operation_id: "op-1", + }); + expect(connectionButtonProgress(start, "connect")).toEqual({ + labelKey: "stages.startHiddify", + percent: 25, + processing: true, + }); + expect(connectionButtonProgress(start, "disconnect").processing).toBe( + false, + ); + + const mihomo = { + ...start, + phase: "starting_core" as const, + operation_stage: "starting_core" as const, + }; + expect(connectionButtonProgress(mihomo, "connect")).toEqual({ + labelKey: "stages.startMihomo", + percent: 70, + processing: true, + }); + }); + + it("uses install milestones before the stack start stages", () => { + expect( + resolveOperationStage(base({ busy: "connecting" }), "hiddify"), + ).toEqual({ + percent: 16, + labelKey: "stages.installHiddify", + }); + }); + + it("maps Disconnect and Pause to their stop stages", () => { + const disconnecting = base({ + phase: "stopping", + busy: "disconnecting", + operation_stage: "stopping_proxy", + hiddify: { phase: "running", message: null, since: now }, + }); + expect(connectionButtonProgress(disconnecting, "disconnect")).toEqual({ + labelKey: "stages.stopHiddify", + percent: 65, + processing: true, + }); + + const pausing = base({ + phase: "stopping", + busy: "pausing", + operation_stage: "stopping_core", + mihomo: { phase: "running", message: null, since: now }, + }); + expect(connectionButtonProgress(pausing, "pause")).toEqual({ + labelKey: "stages.stopMihomo", + percent: 35, + processing: true, + }); + }); + + it("fills to 100% on the last published stage before idle", () => { + const ready = base({ + phase: "checking_readiness", + busy: "resuming", + operation_stage: "checking_readiness", + }); + expect(connectionButtonProgress(ready, "resume").percent).toBe(85); + }); + + it("shows an optimistic preparing fill before the first snapshot", () => { + expect( + connectionButtonProgress(base(), "connect", null, true), + ).toMatchObject({ + labelKey: "stages.preparing", + processing: true, + }); + }); +}); diff --git a/apps/desktop/src/lib/connectionProgress.ts b/apps/desktop/src/lib/connectionProgress.ts new file mode 100644 index 0000000..d13dd63 --- /dev/null +++ b/apps/desktop/src/lib/connectionProgress.ts @@ -0,0 +1,200 @@ +import type { + LifecycleBusy, + OperationStage, + StackSnapshot, +} from "../api/models"; + +export type ConnectionAction = "connect" | "disconnect" | "pause" | "resume"; + +export interface ConnectionButtonProgress { + labelKey: string; + percent: number; + processing: boolean; +} + +const STAGE_META: Record< + OperationStage, + { percent: number; labelKey: string } +> = { + preparing: { percent: 10, labelKey: "stages.preparing" }, + starting_hiddify: { percent: 25, labelKey: "stages.startHiddify" }, + preparing_runtime: { percent: 40, labelKey: "stages.prepareRuntime" }, + validating_config: { percent: 55, labelKey: "stages.validateConfig" }, + starting_core: { percent: 70, labelKey: "stages.startMihomo" }, + checking_readiness: { percent: 85, labelKey: "stages.checkReadiness" }, + stopping_core: { percent: 35, labelKey: "stages.stopMihomo" }, + stopping_proxy: { percent: 65, labelKey: "stages.stopHiddify" }, + cleaning_up: { percent: 85, labelKey: "stages.cleaningUp" }, + recovering: { percent: 50, labelKey: "stages.recovering" }, +}; + +const INSTALL_STAGES: Record = { + helper: { percent: 12, labelKey: "stages.installHelper" }, + hiddify: { percent: 16, labelKey: "stages.installHiddify" }, + mihomo: { percent: 20, labelKey: "stages.installMihomo" }, +}; + +export function busyAction( + busy: LifecycleBusy | null | undefined, +): ConnectionAction | null { + switch (busy) { + case "connecting": + return "connect"; + case "disconnecting": + return "disconnect"; + case "pausing": + return "pause"; + case "resuming": + return "resume"; + default: + return null; + } +} + +export function idleLabelKey(action: ConnectionAction): ConnectionAction { + return action; +} + +export function resolveOperationStage( + snapshot: StackSnapshot, + installingId?: string | null, +): { percent: number; labelKey: string } | null { + if (installingId && INSTALL_STAGES[installingId]) { + return INSTALL_STAGES[installingId]; + } + if (snapshot.operation_stage) { + return STAGE_META[snapshot.operation_stage]; + } + return derivedStage(snapshot); +} + +function derivedStage( + snapshot: StackSnapshot, +): { percent: number; labelKey: string } | null { + const busy = snapshot.busy ?? null; + if (busy === "connecting" || busy === "resuming") { + switch (snapshot.phase) { + case "starting_hiddify": + return STAGE_META.starting_hiddify; + case "preparing_runtime": + return STAGE_META.preparing_runtime; + case "validating_config": + return STAGE_META.validating_config; + case "starting_core": + return STAGE_META.starting_core; + case "checking_readiness": + return STAGE_META.checking_readiness; + case "recovering": + return STAGE_META.recovering; + case "running": + return { percent: 100, labelKey: "stages.checkReadiness" }; + default: + return STAGE_META.preparing; + } + } + if (busy === "disconnecting") { + if (snapshot.phase === "stopped") { + return { percent: 100, labelKey: "stages.cleaningUp" }; + } + if (snapshot.mihomo.phase !== "stopped") { + return STAGE_META.stopping_core; + } + if (snapshot.hiddify.phase !== "stopped") { + return STAGE_META.stopping_proxy; + } + return STAGE_META.cleaning_up; + } + if (busy === "pausing") { + if (snapshot.phase === "paused") { + return { percent: 100, labelKey: "stages.cleaningUp" }; + } + if (snapshot.mihomo.phase !== "stopped") { + return STAGE_META.stopping_core; + } + return STAGE_META.cleaning_up; + } + return null; +} + +function optimisticStage(action: ConnectionAction): { + percent: number; + labelKey: string; +} { + if (action === "connect" || action === "resume") { + return STAGE_META.preparing; + } + return STAGE_META.stopping_core; +} + +export function connectionButtonProgress( + snapshot: StackSnapshot, + action: ConnectionAction, + installingId?: string | null, + actionPending = false, +): ConnectionButtonProgress { + const active = busyAction(snapshot.busy); + if (active === action) { + const stage = + resolveOperationStage(snapshot, installingId) ?? STAGE_META.preparing; + return { + labelKey: stage.labelKey, + percent: stage.percent, + processing: true, + }; + } + if ( + active === null && + actionPending && + installingId && + action === "connect" && + INSTALL_STAGES[installingId] + ) { + const stage = INSTALL_STAGES[installingId]; + return { + labelKey: stage.labelKey, + percent: stage.percent, + processing: true, + }; + } + if ( + active === null && + actionPending && + isLikelyClickedAction(snapshot, action) + ) { + const stage = optimisticStage(action); + return { + labelKey: stage.labelKey, + percent: stage.percent, + processing: true, + }; + } + return { + labelKey: idleLabelKey(action), + percent: 0, + processing: false, + }; +} + +function isLikelyClickedAction( + snapshot: StackSnapshot, + action: ConnectionAction, +): boolean { + switch (action) { + case "connect": + return ( + snapshot.phase === "stopped" || + snapshot.phase === "error" || + snapshot.phase === "uninitialized" + ); + case "resume": + return snapshot.phase === "paused"; + case "pause": + return snapshot.phase === "running" || snapshot.phase === "degraded"; + case "disconnect": + return ( + snapshot.phase === "running" || + snapshot.phase === "degraded" || + snapshot.phase === "paused" + ); + } +} diff --git a/apps/desktop/src/lib/lifecycle.test.ts b/apps/desktop/src/lib/lifecycle.test.ts index f8d6024..cc358ec 100644 --- a/apps/desktop/src/lib/lifecycle.test.ts +++ b/apps/desktop/src/lib/lifecycle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { StackSnapshot } from "../api/models"; -import { controlsLocked } from "./lifecycle"; +import { controlsLocked, isOperating } from "./lifecycle"; const snapshot = (overrides: Partial = {}): StackSnapshot => ({ @@ -25,6 +25,12 @@ describe("controlsLocked", () => { expect(controlsLocked(snapshot({ busy: "resuming" }), false)).toBe(true); }); + it("treats a published busy or transitional phase as operating", () => { + expect(isOperating(snapshot({ busy: "connecting" }))).toBe(true); + expect(isOperating(snapshot({ phase: "starting_core" }))).toBe(true); + expect(isOperating(snapshot({ phase: "running" }))).toBe(false); + }); + it("unlocks after success, failure, or a cleared timeout", () => { expect(controlsLocked(snapshot({ phase: "running" }), false)).toBe(false); expect(controlsLocked(snapshot({ phase: "error" }), false)).toBe(false); diff --git a/apps/desktop/src/lib/lifecycle.ts b/apps/desktop/src/lib/lifecycle.ts index 18c210e..34d08c2 100644 --- a/apps/desktop/src/lib/lifecycle.ts +++ b/apps/desktop/src/lib/lifecycle.ts @@ -18,6 +18,18 @@ export function snapshotBusy( return snapshot?.busy ?? null; } +export function isOperating( + snapshot: StackSnapshot | null | undefined, +): boolean { + if (!snapshot) { + return false; + } + return ( + snapshotBusy(snapshot) !== null || + TRANSITIONAL_PHASES.includes(snapshot.phase) + ); +} + export function controlsLocked( snapshot: StackSnapshot | null | undefined, actionPending: boolean, diff --git a/apps/desktop/src/shell.test.ts b/apps/desktop/src/shell.test.ts index 0d2b018..365296f 100644 --- a/apps/desktop/src/shell.test.ts +++ b/apps/desktop/src/shell.test.ts @@ -31,6 +31,16 @@ describe("fixed desktop shell", () => { ); }); + it("fills connection progress inside the action button", () => { + expect(css).toMatch( + /\.connection-action-fill-clip[\s\S]*overflow:\s*hidden/, + ); + expect(css).toMatch(/\.connection-action-fill[\s\S]*transition:\s*width/); + expect(css).toMatch( + /prefers-reduced-motion: reduce[\s\S]*\.connection-action-fill[\s\S]*transition:\s*none/, + ); + }); + it("draws a square 3px connection glow that sits on the window edges", () => { expect(css).toMatch( /\.connection-glow::after[\s\S]*border-radius:\s*0[\s\S]*border:\s*3px solid/, diff --git a/crates/iran-split-core/src/lib.rs b/crates/iran-split-core/src/lib.rs index d01a35d..0b58e96 100644 --- a/crates/iran-split-core/src/lib.rs +++ b/crates/iran-split-core/src/lib.rs @@ -148,12 +148,49 @@ impl LifecycleBusy { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OperationStage { + Preparing, + StartingHiddify, + PreparingRuntime, + ValidatingConfig, + StartingCore, + CheckingReadiness, + StoppingCore, + StoppingProxy, + CleaningUp, + Recovering, +} + +impl OperationStage { + const fn from_phase(phase: StackPhase) -> Option { + match phase { + StackPhase::StartingHiddify => Some(Self::StartingHiddify), + StackPhase::PreparingRuntime => Some(Self::PreparingRuntime), + StackPhase::ValidatingConfig => Some(Self::ValidatingConfig), + StackPhase::StartingCore => Some(Self::StartingCore), + StackPhase::CheckingReadiness => Some(Self::CheckingReadiness), + StackPhase::Recovering => Some(Self::Recovering), + StackPhase::Uninitialized + | StackPhase::Stopped + | StackPhase::Running + | StackPhase::Paused + | StackPhase::Degraded + | StackPhase::Stopping + | StackPhase::Error => None, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StackSnapshot { pub revision: u64, pub phase: StackPhase, #[serde(default)] pub busy: Option, + #[serde(default)] + pub operation_stage: Option, pub operation_id: Option, pub helper: ComponentStatus, pub hiddify: ComponentStatus, @@ -173,6 +210,7 @@ impl Default for StackSnapshot { revision: 0, phase: StackPhase::Uninitialized, busy: None, + operation_stage: None, operation_id: None, helper: ComponentStatus::default(), hiddify: ComponentStatus::default(), @@ -446,6 +484,14 @@ impl OperationKind { Self::Resume => LifecycleBusy::Resuming, } } + + const fn initial_stage(self) -> OperationStage { + match self { + Self::Reconcile => OperationStage::Recovering, + Self::Start | Self::Resume => OperationStage::Preparing, + Self::Stop | Self::Pause => OperationStage::StoppingCore, + } + } } #[derive(Debug, Clone, Copy)] @@ -726,7 +772,10 @@ impl Engine { return Ok(()); } drop(pending); - self.update(|snapshot| snapshot.busy = Some(kind.busy())); + self.update(|snapshot| { + snapshot.busy = Some(kind.busy()); + snapshot.operation_stage = Some(kind.initial_stage()); + }); info!( event = "operation.reserved", section = "engine", @@ -747,6 +796,7 @@ impl Engine { self.update(|snapshot| { if snapshot.busy == Some(kind.busy()) && snapshot.operation_id.is_none() { snapshot.busy = None; + snapshot.operation_stage = None; } }); } @@ -901,6 +951,7 @@ impl Engine { engine.update(|snapshot| { snapshot.operation_id = None; snapshot.busy = None; + snapshot.operation_stage = None; }); engine.operations.lock().await.remove(&item.id); engine.pending.lock().await.remove(&item.kind); @@ -1001,7 +1052,8 @@ impl Engine { ); }); - self.transition(StackPhase::StartingHiddify, operation_id); + self.announce(StackPhase::StartingHiddify, operation_id) + .await; self.update(|snapshot| { snapshot.hiddify = ComponentStatus::new(ComponentPhase::Starting, None); }); @@ -1011,15 +1063,17 @@ impl Engine { snapshot.hiddify = ComponentStatus::new(ComponentPhase::Running, None); }); - self.transition(StackPhase::PreparingRuntime, operation_id); + self.announce(StackPhase::PreparingRuntime, operation_id) + .await; let generation = self.backend.prepare_runtime().await?; check_cancelled(cancel)?; - self.transition(StackPhase::ValidatingConfig, operation_id); + self.announce(StackPhase::ValidatingConfig, operation_id) + .await; self.backend.validate_runtime(&generation).await?; check_cancelled(cancel)?; - self.transition(StackPhase::StartingCore, operation_id); + self.announce(StackPhase::StartingCore, operation_id).await; self.update(|snapshot| { snapshot.mihomo = ComponentStatus::new(ComponentPhase::Starting, None); snapshot.tun = ComponentStatus::new(ComponentPhase::Starting, None); @@ -1029,7 +1083,8 @@ impl Engine { *core_started = true; check_cancelled(cancel)?; - self.transition(StackPhase::CheckingReadiness, operation_id); + self.announce(StackPhase::CheckingReadiness, operation_id) + .await; let readiness = self.backend.check_readiness(cancel.clone()).await?; if !readiness.controller_ready { return Err(CoreError::ControllerTimeout); @@ -1120,7 +1175,8 @@ impl Engine { self.snapshot().phase, StackPhase::Running | StackPhase::Degraded | StackPhase::Paused ); - self.transition(StackPhase::Stopping, operation_id); + 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!( @@ -1134,7 +1190,9 @@ impl Engine { ); } } + self.announce_stage(OperationStage::StoppingProxy).await; self.backend.stop_user_proxy().await?; + self.announce_stage(OperationStage::CleaningUp).await; let report = self.backend.cleanup_owned_state().await?; let tun = self.backend.tun_status().await?; if tun.active || !report.clean() { @@ -1150,7 +1208,8 @@ impl Engine { } async fn run_pause(&self, operation_id: Uuid) -> Result<(), CoreError> { - self.transition(StackPhase::Stopping, operation_id); + self.announce(StackPhase::Stopping, operation_id).await; + self.announce_stage(OperationStage::StoppingCore).await; if let Err(cause) = self.backend.stop_core().await { warn!( event = "operation.pause_stop_core_failed", @@ -1162,6 +1221,7 @@ impl Engine { "pause continued after core stop failure" ); } + self.announce_stage(OperationStage::CleaningUp).await; let report = self.backend.cleanup_owned_state().await?; let tun = self.backend.tun_status().await?; if tun.active || !report.clean() { @@ -1232,14 +1292,32 @@ impl Engine { self.update(|snapshot| { snapshot.phase = phase; snapshot.operation_id = Some(operation_id); + if let Some(stage) = OperationStage::from_phase(phase) { + snapshot.operation_stage = Some(stage); + } }); } + fn set_operation_stage(&self, stage: OperationStage) { + self.update(|snapshot| snapshot.operation_stage = Some(stage)); + } + + async fn announce(&self, phase: StackPhase, operation_id: Uuid) { + self.transition(phase, operation_id); + tokio::task::yield_now().await; + } + + async fn announce_stage(&self, stage: OperationStage) { + self.set_operation_stage(stage); + tokio::task::yield_now().await; + } + fn set_paused(&self, health: RuntimeHealth) { self.update(|snapshot| { apply_health(snapshot, health); snapshot.phase = StackPhase::Paused; snapshot.operation_id = None; + snapshot.operation_stage = None; snapshot.exit_ip = None; snapshot.mihomo = ComponentStatus::new(ComponentPhase::Stopped, None); snapshot.tun = ComponentStatus::new(ComponentPhase::Stopped, None); @@ -1254,6 +1332,7 @@ impl Engine { apply_health(snapshot, health); snapshot.phase = StackPhase::Stopped; snapshot.operation_id = None; + snapshot.operation_stage = None; snapshot.exit_ip = None; snapshot.last_error = None; }); @@ -1769,6 +1848,49 @@ mod tests { let value = serde_json::to_value(snapshot).expect("serialize"); assert_eq!(value["phase"], "checking_readiness"); assert_eq!(value["busy"], serde_json::Value::Null); + assert_eq!(value["operation_stage"], serde_json::Value::Null); + } + + async fn collect_stages_until( + receiver: &mut watch::Receiver, + desired: StackPhase, + ) -> Vec { + let mut stages = Vec::new(); + loop { + receiver.changed().await.expect("snapshot update"); + let snapshot = receiver.borrow().clone(); + if let Some(stage) = snapshot.operation_stage { + if stages.last() != Some(&stage) { + stages.push(stage); + } + } + if snapshot.phase == desired && snapshot.busy.is_none() { + return stages; + } + } + } + + #[tokio::test] + async fn start_and_stop_publish_real_operation_stages() { + let backend = Arc::new(FakeBackend::default()); + let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current()); + let mut receiver = engine.subscribe(); + let collect = collect_stages_until(&mut receiver, StackPhase::Running); + let start = engine.start_stack(); + let (stages, accepted) = tokio::join!(collect, start); + accepted.expect("start accepted"); + assert!(stages.contains(&OperationStage::StartingHiddify)); + assert!(stages.contains(&OperationStage::StartingCore)); + assert!(stages.contains(&OperationStage::CheckingReadiness)); + + let collect = collect_stages_until(&mut receiver, StackPhase::Stopped); + let stop = engine.stop_stack(); + let (stages, accepted) = tokio::join!(collect, stop); + accepted.expect("stop accepted"); + assert!(stages.contains(&OperationStage::StoppingCore)); + assert!(stages.contains(&OperationStage::StoppingProxy)); + assert!(stages.contains(&OperationStage::CleaningUp)); + assert_eq!(engine.snapshot().operation_stage, None); } #[test] diff --git a/docs/adr/0052-in-button-connection-progress.md b/docs/adr/0052-in-button-connection-progress.md new file mode 100644 index 0000000..3d04857 --- /dev/null +++ b/docs/adr/0052-in-button-connection-progress.md @@ -0,0 +1,30 @@ +# ADR 0052: In-button connection progress + +## Status + +Accepted + +## Context + +Connect, Disconnect, Pause, and Resume showed a standalone progress card +driven by stack phase names. That hid the current action, used a second +status region, and did not describe stop or pause milestones. + +## Decision + +- Publish `StackSnapshot.operation_stage` from the engine at each real + milestone (prepare, start Hiddify/runtime/config/Mihomo/readiness, stop + core, stop proxy, clean up, recover). +- Remove the standalone progress card. The active connection button shows + the stage label and an animated fill whose width is the published percent. +- Progress is never a fake timer. The mock transport emits the same stages + the engine does so UI tests stay honest. +- Keep labels short and wrapping (`break-words`, no `truncate`) so they fit + the 390, 768, and 1024 viewports without clipping or shifting layout. +- Linux and Windows share the same React control and CSS. + +## Consequences + +- Accessible names change to the current stage while an operation runs. +- Cancel remains a separate control. The About updater still owns + `role="progressbar"`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 997e753..af3b8f5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -55,3 +55,4 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th | [0049](./0049-state-aware-tray-menu.md) | State-aware tray menu | Accepted | | [0050](./0050-connection-operation-lock.md) | Connection operation lock | Accepted | | [0051](./0051-button-icons.md) | Icons on every button | Accepted | +| [0052](./0052-in-button-connection-progress.md) | In-button connection progress | Accepted | diff --git a/docs/considering/desktop-sixteen-task-plan.md b/docs/considering/desktop-sixteen-task-plan.md index 3f09132..9a784fb 100644 --- a/docs/considering/desktop-sixteen-task-plan.md +++ b/docs/considering/desktop-sixteen-task-plan.md @@ -39,7 +39,7 @@ requested tasks. Runtime changes land in numbered commits on this branch. 16. **Tray** — Exactly Connect\|Disconnect, Pause\|Resume, Quit, with separators; rebuild on every snapshot. 17. **Connection lock** — One shared lifecycle lock (`connecting` / `disconnecting` / `pausing` / `resuming`) in the engine, tray, and UI. Reject conflicting operations; restore controls after success, failure, or timeout. 18. **Button icons** — Lucide icons on every button; icon-only controls keep a tooltip and accessible name. -19. **In-button progress** — Remove the standalone connect progress card. Fill the active Connect/Disconnect/Pause/Resume button from real stack phases and show the current stage on the label. +19. **In-button progress** — Done: `operation_stage` on the snapshot, standalone card removed, fill and stage label live on the active connection button. 20. **Connect glow** — Subtle available-state glow on Connect; off while disabled/processing; honor `prefers-reduced-motion`. ## Platform notes diff --git a/e2e/primary-flows.spec.ts b/e2e/primary-flows.spec.ts index 73a6b61..8a59fb0 100644 --- a/e2e/primary-flows.spec.ts +++ b/e2e/primary-flows.spec.ts @@ -112,6 +112,15 @@ test.describe("primary BiFlow flows", () => { const connect = connectButton(page); await connect.click(); await expect(connect).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Start Hiddify" }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Start Mihomo" }), + ).toBeDisabled(); + await expect( + page.locator("[data-connection-action='connect']"), + ).toHaveAttribute("data-processing", "true"); await connect.click({ force: true }); await expect( page.getByRole("heading", { name: "Protected split routing is active" }), diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts index 7da67b6..474a3be 100644 --- a/e2e/responsive.spec.ts +++ b/e2e/responsive.spec.ts @@ -107,6 +107,36 @@ test.describe("responsive viewports", () => { expect(next.overlap, name).toBe(false); expect(next.hamburger, name).toBe(false); } + + await page.getByRole("button", { name: "Dashboard" }).click(); + const connect = page.getByRole("button", { + name: "Connect", + exact: true, + }); + await connect.click(); + const processing = page.locator("[data-connection-action='connect']"); + await expect(processing).toHaveAttribute("data-processing", "true"); + const labelBox = await processing + .locator(".connection-action-label") + .boundingBox(); + const buttonBox = await processing.boundingBox(); + expect(labelBox).not.toBeNull(); + expect(buttonBox).not.toBeNull(); + expect(labelBox?.width ?? 0).toBeLessThanOrEqual( + (buttonBox?.width ?? 0) + 1, + ); + const clipped = await processing.evaluate((button) => { + const label = button.querySelector(".connection-action-label"); + if (!(label instanceof HTMLElement)) { + return true; + } + return ( + label.scrollWidth > label.clientWidth + 1 || + label.scrollHeight > label.clientHeight + 1 || + button.scrollWidth > button.clientWidth + 1 + ); + }); + expect(clipped).toBe(false); }); } }); diff --git a/package.json b/package.json index a974db5..3467dae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iran-split-desktop-workspace", - "version": "3.2.9", + "version": "3.3.0", "private": true, "packageManager": "pnpm@9.0.1", "engines": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 66dd292..992f580 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": "3.2.9", + "version": "3.3.0", "identifier": "app.biflow.desktop", "build": { "beforeDevCommand": "pnpm bundle:check && pnpm --dir apps/desktop dev", diff --git a/version b/version index e650c01..15a2799 100644 --- a/version +++ b/version @@ -1 +1 @@ -3.2.9 +3.3.0 From 8f827c9196c3e9e645c55311374006b102006067 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 00:25:14 +0000 Subject: [PATCH 20/21] Add a reduced-motion-aware glow to the available Connect button. Co-authored-by: Dariush vesal --- AGENTS.md | 2 +- Cargo.lock | 22 +++++----- Cargo.toml | 2 +- apps/desktop/package.json | 2 +- .../src/components/BasicDashboard.test.tsx | 1 + .../ConnectionActionButton.test.tsx | 4 ++ .../src/components/ConnectionActionButton.tsx | 4 +- .../desktop/src/components/Dashboard.test.tsx | 2 + apps/desktop/src/index.css | 29 +++++++++++++ apps/desktop/src/shell.test.ts | 9 ++++ apps/desktop/src/vite-env.d.ts | 2 + docs/adr/0053-connect-button-glow.md | 26 ++++++++++++ docs/adr/README.md | 1 + docs/considering/desktop-sixteen-task-plan.md | 2 +- e2e/primary-flows.spec.ts | 42 ++++++++++++++----- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- version | 2 +- 18 files changed, 127 insertions(+), 29 deletions(-) create mode 100644 docs/adr/0053-connect-button-glow.md diff --git a/AGENTS.md b/AGENTS.md index d5be5ce..5b9ca13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ If a required command fails or emits a warning from project code, fix it in the - The diagnostics **Test flow** button stays disabled until the target field is non-empty. - The Zustand store is a process singleton. App tests that change `page` must reset store state in `beforeEach`, or the next test stays on Settings and never sees the dashboard heading. - `getByRole(..., { name: "Install" })` substring-matches **Installing…**. Use `{ name: /^Install$/ }` in Vitest and `{ exact: true }` in Playwright. -- Playwright `getByRole("button", { name: "Connect" })` also matches the status-bar **Internet connected** control. Use `{ name: "Connect", exact: true }`. Basic mode has no sidebar **BiFlow** wordmark, so wait for the mode switch instead. +- Playwright `getByRole("button", { name: "Connect" })` also matches the status-bar **Internet connected** control. Use `{ name: "Connect", exact: true }`. After click the accessible name becomes the current stage, so keep asserting the same control with `[data-connection-action='connect']`. Stage labels last only a few hundred milliseconds, so record them with a `MutationObserver` instead of sequential `getByRole` name waits. Basic mode has no sidebar **BiFlow** wordmark, so wait for the mode switch instead. - `scripts/sync-version.mjs` must only sync manifests when it is the process entry point. Importing `readAppVersion` from tests or `build-plan.mjs` must not rewrite `package.json`. - After installing rustup, the same shell must prepend `$HOME/.cargo/bin` (or `source "$HOME/.cargo/env"`) or `cargo` is still missing. Both `./build.sh` and `./dev.sh` do this before every toolchain check, including clean/non-interactive shells. - Hiddify/Mihomo Install buttons must use PATH and `~/.local/bin`, not only `~/.local/share/biflow`. Mock UI reads the same locations at Vite startup; Playwright still forces missing deps via `sessionStorage` so e2e can test Install. diff --git a/Cargo.lock b/Cargo.lock index 0398cbb..6fe9392 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1950,7 +1950,7 @@ dependencies = [ [[package]] name = "iran-split-cli" -version = "3.3.0" +version = "3.3.1" dependencies = [ "async-trait", "chrono", @@ -1967,7 +1967,7 @@ dependencies = [ [[package]] name = "iran-split-config" -version = "3.3.0" +version = "3.3.1" dependencies = [ "hex", "rand 0.9.5", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "iran-split-core" -version = "3.3.0" +version = "3.3.1" dependencies = [ "async-trait", "chrono", @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "iran-split-desktop" -version = "3.3.0" +version = "3.3.1" dependencies = [ "chrono", "dirs 6.0.0", @@ -2029,7 +2029,7 @@ dependencies = [ [[package]] name = "iran-split-helper" -version = "3.3.0" +version = "3.3.1" dependencies = [ "clap", "hex", @@ -2050,7 +2050,7 @@ dependencies = [ [[package]] name = "iran-split-helper-winacl" -version = "3.3.0" +version = "3.3.1" dependencies = [ "tokio", "windows", @@ -2058,7 +2058,7 @@ dependencies = [ [[package]] name = "iran-split-ipc" -version = "3.3.0" +version = "3.3.1" dependencies = [ "bytes", "chrono", @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "iran-split-mihomo" -version = "3.3.0" +version = "3.3.1" dependencies = [ "chrono", "futures-util", @@ -2093,7 +2093,7 @@ dependencies = [ [[package]] name = "iran-split-platform-linux" -version = "3.3.0" +version = "3.3.1" dependencies = [ "async-trait", "chrono", @@ -2115,7 +2115,7 @@ dependencies = [ [[package]] name = "iran-split-platform-win" -version = "3.3.0" +version = "3.3.1" dependencies = [ "async-trait", "chrono", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "iran-split-rules" -version = "3.3.0" +version = "3.3.1" dependencies = [ "async-trait", "chrono", diff --git a/Cargo.toml b/Cargo.toml index f627f08..98c2915 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "3.3.0" +version = "3.3.1" 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 9a4db69..3f067b5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@iran-split/desktop", - "version": "3.3.0", + "version": "3.3.1", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src/components/BasicDashboard.test.tsx b/apps/desktop/src/components/BasicDashboard.test.tsx index 854c7cb..a8e914a 100644 --- a/apps/desktop/src/components/BasicDashboard.test.tsx +++ b/apps/desktop/src/components/BasicDashboard.test.tsx @@ -38,6 +38,7 @@ describe("BasicDashboard", () => { const connect = screen.getByRole("button", { name: "Start Hiddify" }); expect(connect).toBeDisabled(); expect(connect).toHaveAttribute("data-progress", "25"); + expect(connect).toHaveAttribute("data-connect-glow", "off"); expect(screen.queryByText("%")).toBeNull(); expect(screen.queryByRole("status")).toBeNull(); }); diff --git a/apps/desktop/src/components/ConnectionActionButton.test.tsx b/apps/desktop/src/components/ConnectionActionButton.test.tsx index 5552316..d1c8493 100644 --- a/apps/desktop/src/components/ConnectionActionButton.test.tsx +++ b/apps/desktop/src/components/ConnectionActionButton.test.tsx @@ -39,6 +39,8 @@ describe("ConnectionActionButton", () => { const button = screen.getByRole("button", { name: "Connect" }); expect(button).toHaveAttribute("data-progress", "0"); expect(button).toHaveAttribute("data-processing", "false"); + expect(button).toHaveAttribute("data-connect-glow", "available"); + expect(button.className).toMatch(/connect-button-glow/); expect(button.querySelector(".connection-action-label")?.className).toMatch( /break-words/, ); @@ -68,7 +70,9 @@ describe("ConnectionActionButton", () => { expect(button).toBeDisabled(); expect(button).toHaveAttribute("data-progress", "70"); expect(button).toHaveAttribute("aria-busy", "true"); + expect(button).toHaveAttribute("data-connect-glow", "off"); expect(button.className).toMatch(/connection-action-processing/); + expect(button.className).not.toMatch(/connect-button-glow/); const fill = button.querySelector(".connection-action-fill"); expect(fill).toHaveStyle({ width: "70%" }); }); diff --git a/apps/desktop/src/components/ConnectionActionButton.tsx b/apps/desktop/src/components/ConnectionActionButton.tsx index d1de8a0..e28be81 100644 --- a/apps/desktop/src/components/ConnectionActionButton.tsx +++ b/apps/desktop/src/components/ConnectionActionButton.tsx @@ -33,6 +33,7 @@ export function ConnectionActionButton({ actionPending, ); const label = t(progress.labelKey); + const glow = action === "connect" && !disabled && !progress.processing; return (