diff --git a/AGENTS.md b/AGENTS.md
index 7cb1f3e..5b9ca13 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -76,6 +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 }`. 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.
@@ -163,3 +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 30fa88b..e92d600 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1950,7 +1950,7 @@ dependencies = [
[[package]]
name = "iran-split-cli"
-version = "3.0.1"
+version = "3.3.2"
dependencies = [
"async-trait",
"chrono",
@@ -1967,7 +1967,7 @@ dependencies = [
[[package]]
name = "iran-split-config"
-version = "3.0.1"
+version = "3.3.2"
dependencies = [
"hex",
"rand 0.9.5",
@@ -1979,7 +1979,7 @@ dependencies = [
[[package]]
name = "iran-split-core"
-version = "3.0.1"
+version = "3.3.2"
dependencies = [
"async-trait",
"chrono",
@@ -1994,7 +1994,7 @@ dependencies = [
[[package]]
name = "iran-split-desktop"
-version = "3.0.1"
+version = "3.3.2"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -2003,6 +2003,7 @@ dependencies = [
"iran-split-config",
"iran-split-core",
"iran-split-ipc",
+ "iran-split-mihomo",
"iran-split-platform-linux",
"iran-split-platform-win",
"iran-split-rules",
@@ -2028,7 +2029,7 @@ dependencies = [
[[package]]
name = "iran-split-helper"
-version = "3.0.1"
+version = "3.3.2"
dependencies = [
"clap",
"hex",
@@ -2049,7 +2050,7 @@ dependencies = [
[[package]]
name = "iran-split-helper-winacl"
-version = "3.0.1"
+version = "3.3.2"
dependencies = [
"tokio",
"windows",
@@ -2057,7 +2058,7 @@ dependencies = [
[[package]]
name = "iran-split-ipc"
-version = "3.0.1"
+version = "3.3.2"
dependencies = [
"bytes",
"chrono",
@@ -2070,7 +2071,7 @@ dependencies = [
[[package]]
name = "iran-split-mihomo"
-version = "3.0.1"
+version = "3.3.2"
dependencies = [
"chrono",
"futures-util",
@@ -2092,7 +2093,7 @@ dependencies = [
[[package]]
name = "iran-split-platform-linux"
-version = "3.0.1"
+version = "3.3.2"
dependencies = [
"async-trait",
"chrono",
@@ -2114,7 +2115,7 @@ dependencies = [
[[package]]
name = "iran-split-platform-win"
-version = "3.0.1"
+version = "3.3.2"
dependencies = [
"async-trait",
"chrono",
@@ -2135,7 +2136,7 @@ dependencies = [
[[package]]
name = "iran-split-rules"
-version = "3.0.1"
+version = "3.3.2"
dependencies = [
"async-trait",
"chrono",
diff --git a/Cargo.toml b/Cargo.toml
index bc74c88..bdd92ed 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,7 +15,7 @@ members = [
]
[workspace.package]
-version = "3.0.1"
+version = "3.3.2"
edition = "2021"
license = "MIT OR Apache-2.0"
rust-version = "1.88"
diff --git a/README.md b/README.md
index 270d6f2..502af2e 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,8 @@
+ Features
+ ·
How it works
·
Architecture
@@ -21,8 +23,29 @@
FAQ
+
+
+
+
+
+
+
+
---
+## Features
+
+- **Split routing** — Iranian sites, Iranian IP ranges, and private/LAN traffic stay **DIRECT**. Everything else uses the Hiddify connection you already have.
+- **Connect, Pause, Resume, Disconnect** — One operation at a time. The active button shows the real stage (Start Hiddify, Start Mihomo, and so on) with an in-button progress fill.
+- **Basic and Advanced** — First launch opens Basic. Advanced adds component health, live traffic routes, and extra tools.
+- **Direct rules** — Pin hosts to DIRECT or VPN. Refresh the bundled Iran domain and IP lists from the BiFlow cloud snapshot.
+- **Diagnostics** — Test whether a host would go DIRECT or through the VPN, then move it. Export or clear the local `debug.log`. Fresh Hiddify start repairs a blank Hiddify window without touching subscriptions.
+- **Status bar** — Internet reachability, public IP, approximate country, and lifetime sent/received totals.
+- **In-app install** — Connect can install the privileged helper, Hiddify, and the bundled Mihomo build when they are missing.
+- **English and Persian** — Built-in UI languages, with a tray menu for Connect/Disconnect, Pause/Resume, and Quit.
+- **Linux and Windows** — Debian package, AppImage, portable `.exe`, and NSIS setup. Signed AppImage and NSIS builds can update in-app.
+- **Fits the window** — Desktop sidebar, phone-sized bottom navigation, and a resizable window down to 390×640.
+
## Description
BiFlow is a desktop app for split routing. It keeps Iranian websites, Iranian IP
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 3045adc..7bc7d32 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.3.2",
"private": true,
"type": "module",
"scripts": {
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 55a2f27..28bafd7 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -9,7 +9,7 @@ import { APP_VERSION } from "./version";
beforeEach(() => {
resetMockState();
- localStorage.removeItem(UI_MODE_STORAGE_KEY);
+ localStorage.setItem(UI_MODE_STORAGE_KEY, "advanced");
useAppStore.setState({
loading: true,
actionPending: false,
@@ -63,6 +63,18 @@ describe("App", () => {
expect(screen.getByText("Dariush Vesal")).toBeVisible();
});
+ it("opens Basic mode on a first launch with no stored preference", async () => {
+ localStorage.removeItem(UI_MODE_STORAGE_KEY);
+ render( );
+ expect(
+ await screen.findByRole("heading", { name: "Ready when you are" }),
+ ).toBeVisible();
+ expect(
+ screen.queryByRole("button", { name: "Direct rules" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Connect" })).toBeVisible();
+ });
+
it("hides advanced chrome in Basic mode", async () => {
render( );
expect(
@@ -73,7 +85,21 @@ 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();
+ });
+
+ 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();
});
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index 0d57a81..8de1f89 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -2,6 +2,8 @@ import * as Dialog from "@radix-ui/react-dialog";
import {
Activity,
BookOpen,
+ Download,
+ ExternalLink,
Info,
Languages,
LayoutDashboard,
@@ -15,14 +17,22 @@ import { useTranslation } from "react-i18next";
import logo from "./assets/logo.png";
import { desktop } from "./api/desktop";
import type { StackPhase } from "./api/models";
+import {
+ AppButton,
+ BUTTON_ICON_PX,
+ IconOnlyButton,
+} from "./components/AppButton";
import { About } from "./components/About";
import { BasicDashboard } from "./components/BasicDashboard";
import { Dashboard } from "./components/Dashboard";
import { AppStatusBar } from "./components/AppStatusBar";
+import { InputContextMenu } from "./components/InputContextMenu";
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 +50,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");
@@ -86,6 +99,13 @@ export function App() {
return () => window.clearInterval(interval);
}, []);
+ useEffect(() => {
+ const interval = window.setInterval(() => {
+ void useAppStore.getState().refreshTrafficTotals();
+ }, 2_000);
+ return () => window.clearInterval(interval);
+ }, []);
+
if (store.loading) {
return (
@@ -111,12 +131,15 @@ export function App() {
return (
- {advanced ? (
-
+ {advanced && !mobile ? (
+
-
+
-
+ {
+ setUiMode(mode);
+ if (mode === "basic") {
+ useAppStore.getState().setPage("dashboard");
+ }
+ }}
+ />
{!advanced && store.page !== "about" && store.snapshot ? (
@@ -184,7 +215,9 @@ export function App() {
{store.page === "about" ?
: null}
- {advanced ? : null}
+ {advanced && mobile ? : null}
+
+
{advanced ? (
@@ -207,13 +240,13 @@ export function App() {
) : null}
{missing ? (
- }
className="mt-4 rounded-xl bg-brand px-4 py-2.5 font-semibold text-white"
onClick={() => void store.installDependency(missingId)}
>
{t("install")} {missingId === "mihomo" ? "Mihomo" : "Hiddify"}
-
+
) : null}
{store.installGuide ? (
- }
className="rounded-xl bg-brand px-4 py-2.5 font-semibold text-white"
onClick={() => {
const url = store.installGuide?.download_url;
@@ -261,9 +294,10 @@ export function App() {
}}
>
{t("openDownload")}
-
+
) : null}
-
+
+
{t("close")}
@@ -316,15 +350,15 @@ function ThemeButton({
dark: boolean;
setDark: (dark: boolean) => void;
}) {
+ const label = dark ? "Use light theme" : "Use dark theme";
return (
- setDark(!dark)}
className="rounded-xl p-2 text-muted hover:bg-ink/5 hover:text-ink"
- aria-label={dark ? "Use light theme" : "Use dark theme"}
>
{dark ? : }
-
+
);
}
@@ -336,17 +370,16 @@ function LanguageButton({
change: (language: string) => void;
}) {
return (
- {
const next = language === "fa" ? "en" : "fa";
localStorage.setItem("biflow-language", next);
change(next);
}}
className="rounded-xl p-2 text-muted hover:bg-ink/5 hover:text-ink"
- aria-label="Change language"
>
-
+
);
}
diff --git a/apps/desktop/src/api/desktop.ts b/apps/desktop/src/api/desktop.ts
index 1a8568f..3d48f5c 100644
--- a/apps/desktop/src/api/desktop.ts
+++ b/apps/desktop/src/api/desktop.ts
@@ -16,6 +16,7 @@ import type {
LogEntry,
NetworkStatus,
OperationAccepted,
+ TrafficTotals,
RouteTestResult,
StackSnapshot,
UpdateProgress,
@@ -36,6 +37,9 @@ export const desktop = {
getNetworkStatus(): Promise {
return native ? invoke("get_network_status") : mockApi.getNetworkStatus();
},
+ getTrafficTotals(): Promise {
+ return native ? invoke("get_traffic_totals") : mockApi.getTrafficTotals();
+ },
start(): Promise {
return native ? invoke("start_stack") : mockApi.start();
},
diff --git a/apps/desktop/src/api/mock.test.ts b/apps/desktop/src/api/mock.test.ts
index 631d4a3..832c4e8 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,35 @@ 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/);
+ 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 5c1df6c..9aaa3d4 100644
--- a/apps/desktop/src/api/mock.ts
+++ b/apps/desktop/src/api/mock.ts
@@ -17,9 +17,12 @@ import type {
NetworkStatus,
OperationAccepted,
RouteTestResult,
+ LifecycleBusy,
+ OperationStage,
StackPhase,
StackSnapshot,
UpdateProgress,
+ TrafficTotals,
UpdateStatus,
ValidationIssue,
} from "./models";
@@ -41,6 +44,8 @@ function initialSnapshot(): StackSnapshot {
return {
revision: 1,
phase: "stopped",
+ busy: null,
+ operation_stage: null,
operation_id: null,
helper: helperMissing
? {
@@ -228,6 +233,7 @@ function detectedDependencies(): DependencyStatus[] {
}
let snapshot = initialSnapshot();
+let trafficTotals: TrafficTotals = { sent: 1_048_576, received: 2_097_152 };
let settings = initialSettings();
let directRules = initialDirectRules();
let cloudRules = initialCloudRules();
@@ -376,31 +382,51 @@ 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,
+ operationStage: OperationStage | null = null,
+) {
snapshot = {
...snapshot,
revision: snapshot.revision + 1,
phase,
+ busy,
+ operation_stage: operationStage,
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 };
}
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 = {
@@ -417,7 +443,8 @@ async function runStart(accepted: OperationAccepted) {
},
exit_ip: "203.0.113.42",
};
- emit("running", null);
+ lifecycleBusy = null;
+ emit("running", null, null, null);
logs.push({
timestamp: now(),
level: "info",
@@ -446,20 +473,49 @@ export const mockApi = {
async getNetworkStatus() {
return mockNetworkStatus();
},
+ async getTrafficTotals(): Promise {
+ if (snapshot.phase === "running" || snapshot.phase === "degraded") {
+ trafficTotals = {
+ sent: trafficTotals.sent + 4_096,
+ received: trafficTotals.received + 8_192,
+ };
+ }
+ 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", "preparing");
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", "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,
@@ -470,19 +526,27 @@ 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, 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", "stopping_core");
+ window.setTimeout(() => {
+ emit("stopping", accepted.operation_id, "pausing", "cleaning_up");
+ }, 160);
window.setTimeout(() => {
snapshot = {
...snapshot,
@@ -493,24 +557,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, 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", "preparing");
void runStart(accepted);
return accepted;
},
async cancel(operationId: string) {
if (snapshot.operation_id === operationId) {
- emit("stopped", null);
+ lifecycleBusy = null;
+ emit("stopped", null, null, null);
return true;
}
return false;
@@ -748,9 +819,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()) {
@@ -786,7 +867,9 @@ 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();
directRules = initialDirectRules();
cloudRules = initialCloudRules();
diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts
index dbe8bf5..29b66c7 100644
--- a/apps/desktop/src/api/models.ts
+++ b/apps/desktop/src/api/models.ts
@@ -1,3 +1,22 @@
+export type LifecycleBusy =
+ | "connecting"
+ | "disconnecting"
+ | "pausing"
+ | "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"
@@ -55,6 +74,8 @@ export interface AppError {
export interface StackSnapshot {
revision: number;
phase: StackPhase;
+ busy?: LifecycleBusy | null;
+ operation_stage?: OperationStage | null;
operation_id: string | null;
helper: ComponentStatus;
hiddify: ComponentStatus;
@@ -176,6 +197,11 @@ export interface BootstrapResult {
export type InternetState = "checking" | "online" | "offline";
+export interface TrafficTotals {
+ sent: number;
+ received: number;
+}
+
export interface NetworkStatus {
state: InternetState;
public_ip: string | null;
@@ -208,6 +234,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 +255,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..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")}
- }
className="font-medium text-brand underline-offset-2 hover:underline"
onClick={() => void openRepository()}
>
devlifeX/BiFlow
-
+
@@ -76,6 +83,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 ? (
@@ -113,23 +128,23 @@ export function About() {
: t("updateCheck")}
{showInstall ? (
- }
className="rounded-xl border border-brand px-4 py-2.5 font-semibold text-brand disabled:opacity-60"
disabled={busy}
onClick={() => void installUpdate()}
>
{t("updateInstall", { version: update.version ?? "" })}
-
+
) : null}
{showRetry ? (
- }
className="rounded-xl border border-ink/15 px-4 py-2.5 font-semibold"
onClick={() => void retryUpdate()}
>
{t("updateRetry")}
-
+
) : 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 (
+
+ {icon}
+ {children}
+
+ );
+}
+
+export function IconOnlyButton({
+ label,
+ children,
+ className = "",
+ type = "button",
+ ...props
+}: ButtonHTMLAttributes & { label: string }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/desktop/src/components/AppStatusBar.test.tsx b/apps/desktop/src/components/AppStatusBar.test.tsx
index 83a3161..65a3f09 100644
--- a/apps/desktop/src/components/AppStatusBar.test.tsx
+++ b/apps/desktop/src/components/AppStatusBar.test.tsx
@@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
import { useAppStore } from "../store/app";
import { AppStatusBar } from "./AppStatusBar";
import { countryFlag } from "./country";
@@ -7,6 +8,7 @@ import { countryFlag } from "./country";
describe("AppStatusBar", () => {
it("shows internet, public IP, location, and country flag", () => {
useAppStore.setState({
+ trafficTotals: { sent: 12_345_678, received: 1_099_511_627_776 },
networkStatus: {
state: "online",
public_ip: "203.0.113.8",
@@ -23,6 +25,34 @@ 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")).toHaveTextContent("Sent: 11.77 MiB");
+ expect(screen.getByRole("status")).toHaveTextContent("Received: 1.00 TiB");
+ expect(screen.getByRole("status").className).toMatch(/sticky/);
+ });
+
+ it("refreshes network status once when the IP section is clicked", async () => {
+ const refreshNetworkStatus = vi.fn(async () => undefined);
+ useAppStore.setState({
+ networkRefreshing: false,
+ refreshNetworkStatus,
+ networkStatus: {
+ state: "online",
+ public_ip: "203.0.113.8",
+ country_code: "IR",
+ city: "Tehran",
+ checked_at: "2026-08-13T00:00:00.000Z",
+ detail: null,
+ },
+ });
+ render( );
+ const [internet] = screen.getAllByRole("button", {
+ name: "Refresh connection and IP status",
+ });
+ if (!internet) {
+ throw new Error("internet refresh control is missing");
+ }
+ await userEvent.click(internet);
+ expect(refreshNetworkStatus).toHaveBeenCalledOnce();
});
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..f60f29c 100644
--- a/apps/desktop/src/components/AppStatusBar.tsx
+++ b/apps/desktop/src/components/AppStatusBar.tsx
@@ -1,6 +1,14 @@
-import { LoaderCircle, MapPin, Wifi, WifiOff } from "lucide-react";
+import {
+ ArrowDown,
+ ArrowUp,
+ LoaderCircle,
+ MapPin,
+ Wifi,
+ WifiOff,
+} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { NetworkStatus } from "../api/models";
+import { formatTrafficBytes } from "../lib/formatTraffic";
import { useAppStore } from "../store/app";
import { countryFlag } from "./country";
@@ -23,6 +31,11 @@ function locationLabel(status: NetworkStatus, language: string): string | null {
export function AppStatusBar() {
const { i18n, t } = useTranslation();
const status = useAppStore((state) => state.networkStatus);
+ const refreshing = useAppStore((state) => state.networkRefreshing);
+ const refreshNetworkStatus = useAppStore(
+ (state) => state.refreshNetworkStatus,
+ );
+ const traffic = useAppStore((state) => state.trafficTotals);
const current: NetworkStatus = status ?? {
state: "checking",
public_ip: null,
@@ -33,19 +46,26 @@ export function AppStatusBar() {
};
const location = locationLabel(current, i18n.language);
const flag = countryFlag(current.country_code);
+ const state = refreshing ? "checking" : current.state;
return (
);
diff --git a/apps/desktop/src/components/BasicDashboard.test.tsx b/apps/desktop/src/components/BasicDashboard.test.tsx
new file mode 100644
index 0000000..a8e914a
--- /dev/null
+++ b/apps/desktop/src/components/BasicDashboard.test.tsx
@@ -0,0 +1,45 @@
+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(connect).toHaveAttribute("data-connect-glow", "off");
+ 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 ad06492..431e5be 100644
--- a/apps/desktop/src/components/BasicDashboard.tsx
+++ b/apps/desktop/src/components/BasicDashboard.tsx
@@ -1,15 +1,10 @@
-import { LoaderCircle } 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 type { StackSnapshot } from "../api/models";
+import { controlsLocked, isOperating } from "../lib/lifecycle";
import { useAppStore } from "../store/app";
-
-const progressPhases: StackPhase[] = [
- "starting_hiddify",
- "preparing_runtime",
- "validating_config",
- "starting_core",
- "checking_readiness",
-];
+import { AppButton, BUTTON_ICON_PX } from "./AppButton";
+import { ConnectionActionButton } from "./ConnectionActionButton";
export function BasicDashboard({ snapshot }: { snapshot: StackSnapshot }) {
const { t } = useTranslation();
@@ -21,12 +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 operating =
- progressPhases.includes(snapshot.phase) || snapshot.phase === "stopping";
- const progressIndex = progressPhases.indexOf(snapshot.phase);
+ const locked = controlsLocked(snapshot, actionPending);
+ const operating = isOperating(snapshot);
const missing = snapshot.last_error?.remediation === "install_dependency";
const missingId =
snapshot.last_error?.code === "MIHOMO_NOT_FOUND" ? "mihomo" : "hiddify";
@@ -49,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 ? (
{showError}
{missing ? (
-
}
className="mt-3 rounded-xl bg-brand px-4 py-2 font-semibold text-white"
onClick={() => void installDependency(missingId)}
>
{t("install")} {missingId === "mihomo" ? "Mihomo" : "Hiddify"}
-
+
) : null}
) : 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 ? (
-
void toggleConnection()}
- className="inline-flex min-w-36 items-center justify-center gap-2 rounded-xl bg-brand px-5 py-3 font-semibold text-white disabled:opacity-50"
- >
- {actionPending && !operating ? (
-
- ) : null}
- {active ? t("disconnect") : t("connect")}
-
- ) : (
-
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/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 (
+
+ {items.map(({ page, icon: Icon, labelKey }) => {
+ const active = current === page;
+ return (
+ setPage(page)}
+ className={`flex min-w-0 flex-1 flex-col items-center gap-0.5 rounded-lg px-1 py-2 text-[0.65rem] font-medium ${
+ active ? "text-brand" : "text-muted"
+ }`}
+ >
+
+ {t(labelKey)}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/desktop/src/components/ConnectionActionButton.test.tsx b/apps/desktop/src/components/ConnectionActionButton.test.tsx
new file mode 100644
index 0000000..d1c8493
--- /dev/null
+++ b/apps/desktop/src/components/ConnectionActionButton.test.tsx
@@ -0,0 +1,79 @@
+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).toHaveAttribute("data-connect-glow", "available");
+ expect(button.className).toMatch(/connect-button-glow/);
+ 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).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
new file mode 100644
index 0000000..e28be81
--- /dev/null
+++ b/apps/desktop/src/components/ConnectionActionButton.tsx
@@ -0,0 +1,70 @@
+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);
+ const glow = action === "connect" && !disabled && !progress.processing;
+
+ return (
+
+
+
+
+
+ {icon}
+
+ {label}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/components/Dashboard.test.tsx b/apps/desktop/src/components/Dashboard.test.tsx
index c62170d..9630f1f 100644
--- a/apps/desktop/src/components/Dashboard.test.tsx
+++ b/apps/desktop/src/components/Dashboard.test.tsx
@@ -20,6 +20,10 @@ vi.mock("../api/desktop", () => ({
.fn()
.mockResolvedValue({ operation_id: "op", already_complete: false }),
cancel: vi.fn().mockResolvedValue(true),
+ installHelper: vi.fn().mockResolvedValue({ installed: true }),
+ installDependency: vi.fn(),
+ listDependencies: vi.fn(),
+ getSnapshot: vi.fn(),
},
}));
@@ -45,16 +49,64 @@ 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();
+ expect(connect).toHaveAttribute("data-connect-glow", "available");
+ await userEvent.click(connect);
expect(useAppStore.getState().actionPending).toBe(true);
});
- it("exposes cancellation during an operation", () => {
+ it("disables every lifecycle control during a transition", () => {
+ const { rerender } = render(
+ ,
+ );
+ const connecting = screen.getByRole("button", { name: "Start Hiddify" });
+ expect(connecting).toBeDisabled();
+ expect(connecting).toHaveAttribute("data-progress", "25");
+ expect(connecting).toHaveAttribute("data-connect-glow", "off");
+ expect(
+ screen.getByRole("button", { name: "Cancel operation" }),
+ ).toBeEnabled();
+ expect(screen.queryByText("%")).toBeNull();
+
+ const running = {
+ phase: "running" as const,
+ message: "Ready",
+ since: now,
+ };
+ rerender(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Stop Mihomo" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Disconnect" })).toBeDisabled();
+ });
+
+ it("exposes cancellation and in-button progress during an operation", () => {
render(
,
@@ -62,7 +114,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 () => {
@@ -226,4 +281,18 @@ describe("Dashboard", () => {
await userEvent.click(screen.getByRole("button", { name: "Resume" }));
expect(useAppStore.getState().actionPending).toBe(true);
});
+
+ it("lets metric values wrap instead of clipping on narrow columns", () => {
+ render( );
+ const exitIp = screen.getByText("Available after connection");
+ expect(exitIp.className).toMatch(/break-words/);
+ expect(exitIp.className).not.toMatch(/truncate/);
+ });
+
+ 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..722cb0b 100644
--- a/apps/desktop/src/components/Dashboard.tsx
+++ b/apps/desktop/src/components/Dashboard.tsx
@@ -7,21 +7,21 @@ 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 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 {
@@ -38,9 +38,8 @@ export function Dashboard({ snapshot }: { snapshot: StackSnapshot }) {
} = useAppStore();
const active = snapshot.phase === "running" || snapshot.phase === "degraded";
const paused = snapshot.phase === "paused";
- const operating =
- progressPhases.includes(snapshot.phase) || snapshot.phase === "stopping";
- const progressIndex = progressPhases.indexOf(snapshot.phase);
+ const locked = controlsLocked(snapshot, actionPending);
+ const operating = isOperating(snapshot);
const needsAttention = [
snapshot.helper,
snapshot.hiddify,
@@ -52,7 +51,7 @@ export function Dashboard({ snapshot }: { snapshot: StackSnapshot }) {
return (
@@ -71,84 +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 ? (
-
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")}
-
- ) : (
-
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}
-
}
@@ -234,7 +208,7 @@ function Metric({
{icon}
{label}
-
+
{value}
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.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..5315915
--- /dev/null
+++ b/apps/desktop/src/components/InputContextMenu.tsx
@@ -0,0 +1,164 @@
+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;
+ 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()}
+ >
+ }
+ label={t("contextSelectAll")}
+ disabled={menu.field.disabled}
+ onSelect={() => {
+ menu.field.focus();
+ menu.field.select();
+ }}
+ />
+ }
+ label={t("contextCopy")}
+ disabled={!hasSelection}
+ onSelect={() => {
+ void copySelection(menu.field);
+ }}
+ />
+ }
+ label={t("contextCut")}
+ disabled={readonly || !hasSelection}
+ onSelect={() => {
+ void cutSelection(menu.field);
+ }}
+ />
+ }
+ label={t("contextPaste")}
+ disabled={readonly}
+ onSelect={() => {
+ void pasteInto(menu.field);
+ }}
+ />
+
+ );
+}
+
+function MenuItem({
+ icon,
+ label,
+ disabled,
+ onSelect,
+}: {
+ icon: ReactNode;
+ label: string;
+ disabled: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
+ );
+}
+
+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/components/UiModeSwitch.test.tsx b/apps/desktop/src/components/UiModeSwitch.test.tsx
index 0a1ea14..7036342 100644
--- a/apps/desktop/src/components/UiModeSwitch.test.tsx
+++ b/apps/desktop/src/components/UiModeSwitch.test.tsx
@@ -9,8 +9,8 @@ describe("UiModeSwitch", () => {
localStorage.removeItem(UI_MODE_STORAGE_KEY);
});
- it("defaults existing users to Advanced when no preference is stored", () => {
- expect(readUiMode()).toBe("advanced");
+ it("defaults a first launch to Basic when no preference is stored", () => {
+ expect(readUiMode()).toBe("basic");
});
it("persists Basic and Advanced selections", async () => {
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/apps/desktop/src/i18n/config.ts b/apps/desktop/src/i18n/config.ts
index a263594..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",
@@ -39,7 +54,14 @@ 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",
+ trafficReceived: "Received",
internet: {
checking: "Checking internet",
online: "Internet connected",
@@ -68,7 +90,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.",
@@ -134,6 +159,8 @@ const resources = {
tunCleanupFailed:
"The owned TUN or routes could not be completely removed.",
operationCancelled: "The operation was cancelled.",
+ operationInProgress: "Another connection operation is already running.",
+ operationTimeout: "The connection operation timed out.",
internal: "An internal error occurred.",
},
},
@@ -151,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: "وضعیت",
@@ -175,7 +217,14 @@ const resources = {
"نمای متحرک ترافیک خروجی دستگاه که بین مسیر مستقیم و ویپیان تقسیم میشود",
device: "این دستگاه",
lastUpdated: "آخرین بروزرسانی",
+ contextSelectAll: "انتخاب همه",
+ contextCopy: "رونوشت",
+ contextCut: "برش",
+ contextPaste: "چسباندن",
currentIp: "آیپی فعلی",
+ refreshNetwork: "تازهسازی وضعیت اتصال و آیپی",
+ trafficSent: "ارسال",
+ trafficReceived: "دریافت",
internet: {
checking: "در حال بررسی اینترنت",
online: "اینترنت متصل است",
@@ -203,7 +252,10 @@ const resources = {
aboutVersion: "نسخه {{version}}",
aboutUpdatesTitle: "بهروزرسانی",
aboutUpdatesHelp:
- "وقتی منتشر شود، بهروزرسانیهای امضاشده BiFlow را از GitHub Releases بررسی کنید.",
+ "بهروزرسانی امضاشده BiFlow را از GitHub Releases بررسی کنید، سپس قوانین ایران و بسته Mihomo را تازه کنید.",
+ updateRulesAvailable: "نسخه تازهتری از قوانین ایران آماده است.",
+ updateThirdpartyAvailable:
+ "بسته Mihomo همراه برنامه موجود نیست و نصب خواهد شد.",
updateCheck: "بررسی بهروزرسانی",
updateChecking: "در حال بررسی…",
updateCurrent: "آخرین نسخه منتشرشده را دارید.",
@@ -268,6 +320,8 @@ const resources = {
providerNotReady: "یک یا چند فراهمکننده قانون آماده نیست.",
tunCleanupFailed: "تونل یا مسیرها بهطور کامل حذف نشدند.",
operationCancelled: "عملیات لغو شد.",
+ operationInProgress: "عملیات اتصال دیگری در حال اجرا است.",
+ operationTimeout: "عملیات اتصال بیش از حد طول کشید.",
internal: "خطای داخلی رخ داد.",
},
},
diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css
index ce7255f..c930b23 100644
--- a/apps/desktop/src/index.css
+++ b/apps/desktop/src/index.css
@@ -154,18 +154,19 @@ select:focus-visible,
/* Connection glow: the window border breathes while the stack is live, so the
state is readable from across the room without reading the dashboard. The
ring is drawn on an inset overlay rather than the shell's own border so it
- never shifts the fixed 1120x760 layout. */
+ never shifts layout. Square corners keep the ring flush with the window
+ edges at 100% and scaled displays. */
.connection-glow::after {
content: "";
position: fixed;
inset: 0;
z-index: 50;
pointer-events: none;
- border-radius: 0.5rem;
- border: 2px solid rgb(var(--glow) / 0.85);
+ border-radius: 0;
+ border: 3px solid rgb(var(--glow) / 0.9);
box-shadow:
- inset 0 0 12px rgb(var(--glow) / 0.45),
- 0 0 10px rgb(var(--glow) / 0.35);
+ inset 0 0 18px rgb(var(--glow) / 0.55),
+ 0 0 16px rgb(var(--glow) / 0.5);
animation: connection-glow-pulse 2.4s ease-in-out infinite;
}
@@ -198,3 +199,69 @@ 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;
+ }
+}
+
+/* Available Connect only: a quiet pulse that sits on the button box-shadow
+ so the in-button progress fill can keep animating independently. */
+.connect-button-glow {
+ animation: connect-button-glow-pulse 2.2s ease-in-out infinite;
+}
+
+@keyframes connect-button-glow-pulse {
+ 0%,
+ 100% {
+ box-shadow:
+ 0 0 0 2px rgb(var(--brand) / 0.22),
+ 0 0 14px rgb(var(--brand) / 0.28),
+ 0 10px 18px rgb(var(--brand) / 0.16);
+ }
+ 50% {
+ box-shadow:
+ 0 0 0 3px rgb(var(--brand) / 0.38),
+ 0 0 22px rgb(var(--brand) / 0.4),
+ 0 12px 22px rgb(var(--brand) / 0.2);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .connect-button-glow {
+ animation: none;
+ box-shadow: 0 0 0 2px rgb(var(--brand) / 0.28);
+ }
+}
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/connectRequirements.test.ts b/apps/desktop/src/lib/connectRequirements.test.ts
new file mode 100644
index 0000000..aaf430d
--- /dev/null
+++ b/apps/desktop/src/lib/connectRequirements.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import type { DependencyStatus, StackSnapshot } from "../api/models";
+import { missingConnectRequirements } from "./connectRequirements";
+
+const snapshot = (phase: StackSnapshot["helper"]["phase"]): StackSnapshot =>
+ ({
+ helper: { phase, message: null, since: "now" },
+ }) as StackSnapshot;
+
+const deps = (hiddify: boolean, mihomo: boolean): DependencyStatus[] => [
+ {
+ id: "hiddify",
+ name: "Hiddify",
+ installed: hiddify,
+ version: null,
+ path: null,
+ },
+ {
+ id: "mihomo",
+ name: "Mihomo",
+ installed: mihomo,
+ version: null,
+ path: null,
+ },
+];
+
+describe("missingConnectRequirements", () => {
+ it("installs helper, Hiddify, then Mihomo in that order", () => {
+ expect(
+ missingConnectRequirements(snapshot("unavailable"), deps(false, false)),
+ ).toEqual(["helper", "hiddify", "mihomo"]);
+ });
+
+ it("skips services that are already present", () => {
+ expect(
+ missingConnectRequirements(snapshot("running"), deps(true, true)),
+ ).toEqual([]);
+ });
+
+ it("does not invent missing apps when the dependency list is empty", () => {
+ expect(missingConnectRequirements(snapshot("running"), [])).toEqual([]);
+ });
+});
diff --git a/apps/desktop/src/lib/connectRequirements.ts b/apps/desktop/src/lib/connectRequirements.ts
new file mode 100644
index 0000000..4a75350
--- /dev/null
+++ b/apps/desktop/src/lib/connectRequirements.ts
@@ -0,0 +1,29 @@
+import type { DependencyStatus, StackSnapshot } from "../api/models";
+
+export type ConnectRequirement = "helper" | "hiddify" | "mihomo";
+
+export function helperNeedsInstall(
+ snapshot: StackSnapshot | null | undefined,
+): boolean {
+ const phase = snapshot?.helper.phase;
+ return phase === "unavailable" || phase === "error";
+}
+
+export function missingConnectRequirements(
+ snapshot: StackSnapshot | null | undefined,
+ dependencies: DependencyStatus[],
+): ConnectRequirement[] {
+ const missing: ConnectRequirement[] = [];
+ if (helperNeedsInstall(snapshot)) {
+ missing.push("helper");
+ }
+ const hiddify = dependencies.find((item) => item.id === "hiddify");
+ const mihomo = dependencies.find((item) => item.id === "mihomo");
+ if (hiddify && !hiddify.installed) {
+ missing.push("hiddify");
+ }
+ if (mihomo && !mihomo.installed) {
+ missing.push("mihomo");
+ }
+ return missing;
+}
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/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/lib/formatTraffic.test.ts b/apps/desktop/src/lib/formatTraffic.test.ts
new file mode 100644
index 0000000..b59ebe6
--- /dev/null
+++ b/apps/desktop/src/lib/formatTraffic.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { formatTrafficBytes } from "./formatTraffic";
+
+describe("formatTrafficBytes", () => {
+ it("keeps exact bytes below one kibibyte", () => {
+ expect(formatTrafficBytes(0)).toBe("0 B");
+ expect(formatTrafficBytes(512)).toBe("512 B");
+ expect(formatTrafficBytes(1023)).toBe("1023 B");
+ });
+
+ it("uses two decimals for mid-range totals and one for large values", () => {
+ expect(formatTrafficBytes(1024)).toBe("1.00 KiB");
+ expect(formatTrafficBytes(1_048_576)).toBe("1.00 MiB");
+ expect(formatTrafficBytes(12_345_678)).toBe("11.77 MiB");
+ expect(formatTrafficBytes(10_737_418_240)).toBe("10.00 GiB");
+ expect(formatTrafficBytes(1_099_511_627_776)).toBe("1.00 TiB");
+ expect(formatTrafficBytes(123_480_309_760)).toBe("115.0 GiB");
+ });
+});
diff --git a/apps/desktop/src/lib/formatTraffic.ts b/apps/desktop/src/lib/formatTraffic.ts
new file mode 100644
index 0000000..0ff7b0f
--- /dev/null
+++ b/apps/desktop/src/lib/formatTraffic.ts
@@ -0,0 +1,19 @@
+const UNITS = ["KiB", "MiB", "GiB", "TiB", "PiB"] as const;
+
+/** Formats a byte count with enough precision for lifetime VPN totals. */
+export function formatTrafficBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) {
+ return "0 B";
+ }
+ if (bytes < 1024) {
+ return `${Math.round(bytes)} B`;
+ }
+ let value = bytes / 1024;
+ let unit = 0;
+ while (value >= 1024 && unit < UNITS.length - 1) {
+ value /= 1024;
+ unit += 1;
+ }
+ const digits = value < 100 ? 2 : 1;
+ return `${value.toFixed(digits)} ${UNITS[unit]}`;
+}
diff --git a/apps/desktop/src/lib/lifecycle.test.ts b/apps/desktop/src/lib/lifecycle.test.ts
new file mode 100644
index 0000000..cc358ec
--- /dev/null
+++ b/apps/desktop/src/lib/lifecycle.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import type { StackSnapshot } from "../api/models";
+import { controlsLocked, isOperating } from "./lifecycle";
+
+const snapshot = (overrides: Partial = {}): StackSnapshot =>
+ ({
+ revision: 1,
+ phase: "stopped",
+ busy: null,
+ operation_id: null,
+ ...overrides,
+ }) as StackSnapshot;
+
+describe("controlsLocked", () => {
+ it("locks immediately while a store action is pending", () => {
+ expect(controlsLocked(snapshot(), true)).toBe(true);
+ });
+
+ it("locks for every lifecycle busy state", () => {
+ expect(controlsLocked(snapshot({ busy: "connecting" }), false)).toBe(true);
+ expect(controlsLocked(snapshot({ busy: "disconnecting" }), false)).toBe(
+ true,
+ );
+ expect(controlsLocked(snapshot({ busy: "pausing" }), false)).toBe(true);
+ 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);
+ expect(controlsLocked(snapshot({ phase: "stopped" }), false)).toBe(false);
+ expect(controlsLocked(snapshot({ phase: "paused" }), false)).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/lib/lifecycle.ts b/apps/desktop/src/lib/lifecycle.ts
new file mode 100644
index 0000000..34d08c2
--- /dev/null
+++ b/apps/desktop/src/lib/lifecycle.ts
@@ -0,0 +1,47 @@
+import type { LifecycleBusy, StackPhase, StackSnapshot } from "../api/models";
+
+export const TRANSITIONAL_PHASES: StackPhase[] = [
+ "starting_hiddify",
+ "preparing_runtime",
+ "validating_config",
+ "starting_core",
+ "checking_readiness",
+ "stopping",
+ "recovering",
+];
+
+export const ACTION_TIMEOUT_MS = 130_000;
+
+export function snapshotBusy(
+ snapshot: StackSnapshot | null | undefined,
+): LifecycleBusy | null {
+ 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,
+): boolean {
+ if (actionPending) {
+ return true;
+ }
+ if (!snapshot) {
+ return false;
+ }
+ return (
+ snapshotBusy(snapshot) !== null ||
+ TRANSITIONAL_PHASES.includes(snapshot.phase)
+ );
+}
diff --git a/apps/desktop/src/lib/uiMode.ts b/apps/desktop/src/lib/uiMode.ts
index 40cf990..9ddc13b 100644
--- a/apps/desktop/src/lib/uiMode.ts
+++ b/apps/desktop/src/lib/uiMode.ts
@@ -5,7 +5,7 @@ export const UI_MODE_STORAGE_KEY = "biflow-ui-mode-v1";
export function readUiMode(): UiMode {
const stored = localStorage.getItem(UI_MODE_STORAGE_KEY);
if (stored === "basic" || stored === "advanced") return stored;
- return "advanced";
+ return "basic";
}
export function writeUiMode(mode: UiMode): void {
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/apps/desktop/src/shell.test.ts b/apps/desktop/src/shell.test.ts
index 70c4694..274fa4d 100644
--- a/apps/desktop/src/shell.test.ts
+++ b/apps/desktop/src/shell.test.ts
@@ -31,6 +31,35 @@ describe("fixed desktop shell", () => {
);
});
+ it("pulses a Connect glow only when the button is available", () => {
+ expect(css).toMatch(
+ /\.connect-button-glow[\s\S]*connect-button-glow-pulse/,
+ );
+ expect(css).toMatch(
+ /prefers-reduced-motion: reduce[\s\S]*\.connect-button-glow[\s\S]*animation:\s*none/,
+ );
+ });
+
+ 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/,
+ );
+ expect(css).toMatch(/inset 0 0 18px/);
+ expect(css).not.toMatch(
+ /\.connection-glow::after[\s\S]*border-radius:\s*0\.5rem/,
+ );
+ });
+
it("prevents contextmenu events at runtime", () => {
installContextMenuGuard();
const event = new MouseEvent("contextmenu", {
@@ -40,4 +69,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/apps/desktop/src/store/app.test.ts b/apps/desktop/src/store/app.test.ts
index bb1b945..af59f9e 100644
--- a/apps/desktop/src/store/app.test.ts
+++ b/apps/desktop/src/store/app.test.ts
@@ -19,6 +19,7 @@ vi.mock("../api/desktop", () => ({
getSnapshot: vi.fn(),
syncCloudRules: vi.fn(),
getNetworkStatus: vi.fn(),
+ getTrafficTotals: vi.fn(),
checkUpdate: vi.fn(),
installUpdate: vi.fn(),
openUrl: vi.fn(),
@@ -88,6 +89,8 @@ describe("app store", () => {
cloudRules: null,
dependencies: [],
networkStatus: null,
+ trafficTotals: { sent: 0, received: 0 },
+ trafficRefreshing: false,
diagnostics: null,
error: null,
installGuide: null,
@@ -110,12 +113,99 @@ describe("app store", () => {
checked_at: "now",
detail: null,
});
+ vi.mocked(desktop.getTrafficTotals).mockResolvedValue({
+ sent: 2048,
+ received: 4096,
+ });
await useAppStore.getState().initialize();
const state = useAppStore.getState();
expect(state.loading).toBe(false);
expect(state.cloudRules?.domain_count).toBe(10);
expect(state.dependencies[0]?.id).toBe("hiddify");
expect(desktop.getNetworkStatus).toHaveBeenCalledOnce();
+ expect(desktop.getTrafficTotals).toHaveBeenCalledOnce();
+ expect(state.trafficTotals).toEqual({ sent: 2048, received: 4096 });
+ });
+
+ it("keeps lifetime traffic totals when a later probe fails", async () => {
+ useAppStore.setState({
+ trafficTotals: { sent: 9_000, received: 8_000 },
+ trafficRefreshing: false,
+ });
+ vi.mocked(desktop.getTrafficTotals).mockRejectedValue(
+ new Error("controller offline"),
+ );
+ await useAppStore.getState().refreshTrafficTotals();
+ expect(useAppStore.getState().trafficTotals).toEqual({
+ sent: 9_000,
+ received: 8_000,
+ });
+ expect(useAppStore.getState().trafficRefreshing).toBe(false);
+ });
+
+ it("ignores a second traffic refresh while one is in flight", async () => {
+ useAppStore.setState({ trafficRefreshing: true });
+ await useAppStore.getState().refreshTrafficTotals();
+ expect(desktop.getTrafficTotals).not.toHaveBeenCalled();
+ });
+
+ it("starts only one backend operation when Connect is clicked twice", async () => {
+ vi.mocked(desktop.start).mockResolvedValue({
+ operation_id: "op",
+ already_complete: false,
+ });
+ useAppStore.setState({
+ snapshot: boot.snapshot,
+ actionPending: false,
+ dependencies: [
+ {
+ id: "hiddify",
+ name: "Hiddify",
+ installed: true,
+ version: null,
+ path: "/tmp/hiddify",
+ },
+ {
+ id: "mihomo",
+ name: "Mihomo",
+ installed: true,
+ version: null,
+ path: "/tmp/mihomo",
+ },
+ ],
+ });
+ const first = useAppStore.getState().toggleConnection();
+ await useAppStore.getState().toggleConnection();
+ await first;
+ expect(desktop.start).toHaveBeenCalledOnce();
+ expect(useAppStore.getState().actionPending).toBe(true);
+ });
+
+ it("restores controls after a failed connection command", async () => {
+ vi.mocked(desktop.start).mockRejectedValue(new Error("helper missing"));
+ useAppStore.setState({
+ snapshot: boot.snapshot,
+ actionPending: false,
+ dependencies: [
+ {
+ id: "hiddify",
+ name: "Hiddify",
+ installed: true,
+ version: null,
+ path: "/tmp/hiddify",
+ },
+ {
+ id: "mihomo",
+ name: "Mihomo",
+ installed: true,
+ version: null,
+ path: "/tmp/mihomo",
+ },
+ ],
+ });
+ await useAppStore.getState().toggleConnection();
+ expect(useAppStore.getState().actionPending).toBe(false);
+ expect(useAppStore.getState().error).toMatch(/helper missing/);
});
it("starts the stack from a stopped snapshot", async () => {
@@ -123,12 +213,131 @@ describe("app store", () => {
operation_id: "op",
already_complete: false,
});
- useAppStore.setState({ snapshot: boot.snapshot, actionPending: false });
+ useAppStore.setState({
+ snapshot: boot.snapshot,
+ actionPending: false,
+ dependencies: [
+ {
+ id: "hiddify",
+ name: "Hiddify",
+ installed: true,
+ version: null,
+ path: "/tmp/hiddify",
+ },
+ {
+ id: "mihomo",
+ name: "Mihomo",
+ installed: true,
+ version: null,
+ path: "/tmp/mihomo",
+ },
+ ],
+ });
await useAppStore.getState().toggleConnection();
expect(desktop.start).toHaveBeenCalledOnce();
+ expect(desktop.installHelper).not.toHaveBeenCalled();
+ expect(desktop.installDependency).not.toHaveBeenCalled();
expect(useAppStore.getState().actionPending).toBe(true);
});
+ it("installs helper then apps before connecting", async () => {
+ const order: string[] = [];
+ vi.mocked(desktop.installHelper).mockImplementation(async () => {
+ order.push("helper");
+ return { installed: true };
+ });
+ vi.mocked(desktop.getSnapshot).mockResolvedValue({
+ ...boot.snapshot,
+ helper: { phase: "running", message: "ready", since: "now" },
+ });
+ vi.mocked(desktop.installDependency).mockImplementation(async (id) => {
+ order.push(id);
+ return {
+ id,
+ installed: true,
+ path: `/tmp/${id}`,
+ guide: {
+ id,
+ title: id,
+ download_url: "https://example.invalid",
+ steps: [],
+ },
+ };
+ });
+ vi.mocked(desktop.listDependencies).mockResolvedValue([
+ {
+ id: "hiddify",
+ name: "Hiddify",
+ installed: true,
+ version: null,
+ path: "/tmp/hiddify",
+ },
+ {
+ id: "mihomo",
+ name: "Mihomo",
+ installed: true,
+ version: null,
+ path: "/tmp/mihomo",
+ },
+ ]);
+ vi.mocked(desktop.start).mockResolvedValue({
+ operation_id: "op",
+ already_complete: false,
+ });
+ useAppStore.setState({
+ snapshot: {
+ ...boot.snapshot,
+ helper: { phase: "unavailable", message: "missing", since: "now" },
+ },
+ dependencies: [
+ {
+ id: "hiddify",
+ name: "Hiddify",
+ installed: false,
+ version: null,
+ path: null,
+ },
+ {
+ id: "mihomo",
+ name: "Mihomo",
+ installed: false,
+ version: null,
+ path: null,
+ },
+ ],
+ actionPending: false,
+ });
+ await useAppStore.getState().toggleConnection();
+ expect(order).toEqual(["helper", "hiddify", "mihomo"]);
+ expect(desktop.start).toHaveBeenCalledOnce();
+ expect(useAppStore.getState().installingId).toBeNull();
+ });
+
+ it("stops before start when a required install fails", async () => {
+ vi.mocked(desktop.installDependency).mockResolvedValue({
+ id: "hiddify",
+ installed: false,
+ path: null,
+ guide: {
+ id: "hiddify",
+ title: "Install Hiddify",
+ download_url: "https://example.invalid",
+ steps: ["Download"],
+ },
+ });
+ vi.mocked(desktop.listDependencies).mockResolvedValue(boot.dependencies);
+ useAppStore.setState({
+ snapshot: boot.snapshot,
+ dependencies: boot.dependencies,
+ actionPending: false,
+ });
+ await useAppStore.getState().toggleConnection();
+ expect(desktop.start).not.toHaveBeenCalled();
+ expect(useAppStore.getState().error).toMatch(/hiddify installation/);
+ expect(useAppStore.getState().installGuide?.id).toBe("hiddify");
+ expect(useAppStore.getState().actionPending).toBe(false);
+ });
+
it("pauses and resumes from a running snapshot", async () => {
vi.mocked(desktop.pause).mockResolvedValue({
operation_id: "pause",
@@ -192,11 +401,26 @@ describe("app store", () => {
expect(useAppStore.getState().actionPending).toBe(false);
});
+ it("ignores a second network refresh while one is in flight", async () => {
+ useAppStore.setState({ networkRefreshing: true });
+ await useAppStore.getState().refreshNetworkStatus();
+ expect(desktop.getNetworkStatus).not.toHaveBeenCalled();
+ });
+
+ 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,
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");
@@ -208,6 +432,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 4573e92..cf34a03 100644
--- a/apps/desktop/src/store/app.ts
+++ b/apps/desktop/src/store/app.ts
@@ -1,5 +1,7 @@
import { create } from "zustand";
import { desktop } from "../api/desktop";
+import { missingConnectRequirements } from "../lib/connectRequirements";
+import { ACTION_TIMEOUT_MS, controlsLocked } from "../lib/lifecycle";
import type {
AppConfig,
BootstrapResult,
@@ -10,6 +12,7 @@ import type {
InstallGuide,
NetworkStatus,
StackSnapshot,
+ TrafficTotals,
UpdateProgress,
UpdateStatus,
} from "../api/models";
@@ -21,6 +24,9 @@ const initialUpdateProgress = (): UpdateProgress => ({
percent: null,
version: null,
error: null,
+ app_available: false,
+ rules_available: false,
+ thirdparty_available: false,
});
interface AppStore {
@@ -35,6 +41,9 @@ interface AppStore {
cloudRules: CloudRulesStatus | null;
dependencies: DependencyStatus[];
networkStatus: NetworkStatus | null;
+ networkRefreshing: boolean;
+ trafficTotals: TrafficTotals;
+ trafficRefreshing: boolean;
diagnostics: DiagnosticsReport | null;
error: string | null;
installGuide: InstallGuide | null;
@@ -52,6 +61,7 @@ interface AppStore {
refreshRules: () => Promise;
syncCloudRules: () => Promise;
refreshNetworkStatus: () => Promise;
+ refreshTrafficTotals: () => Promise;
installDependency: (id: string) => Promise;
installHelper: () => Promise;
runDiagnostics: () => Promise;
@@ -70,6 +80,44 @@ function message(error: unknown): string {
return "An unexpected error occurred";
}
+async function ensureRequiredServices(
+ get: () => AppStore,
+ set: (partial: Partial) => void,
+): Promise {
+ const missing = missingConnectRequirements(
+ get().snapshot,
+ get().dependencies,
+ );
+ for (const id of missing) {
+ if (id === "helper") {
+ set({ installingId: "helper" });
+ await desktop.installHelper();
+ const snapshot = await desktop.getSnapshot();
+ set({ snapshot });
+ if (
+ snapshot.helper.phase === "unavailable" ||
+ snapshot.helper.phase === "error"
+ ) {
+ throw new Error(
+ "privileged helper is still unavailable after installation",
+ );
+ }
+ continue;
+ }
+ set({ installingId: id });
+ const result = await desktop.installDependency(id);
+ const dependencies = await desktop.listDependencies();
+ set({
+ dependencies,
+ installGuide: result.installed ? null : result.guide,
+ });
+ if (!result.installed) {
+ throw new Error(`${id} installation did not complete`);
+ }
+ }
+ set({ installingId: null });
+}
+
export const useAppStore = create((set, get) => ({
loading: true,
actionPending: false,
@@ -82,6 +130,9 @@ export const useAppStore = create((set, get) => ({
cloudRules: null,
dependencies: [],
networkStatus: null,
+ networkRefreshing: false,
+ trafficTotals: { sent: 0, received: 0 },
+ trafficRefreshing: false,
diagnostics: null,
error: null,
installGuide: null,
@@ -102,9 +153,14 @@ export const useAppStore = create((set, get) => ({
update: initialUpdateProgress(),
});
void get().refreshNetworkStatus();
- const unsubscribeSnapshot = await desktop.subscribe((snapshot) =>
- set({ snapshot, actionPending: false }),
- );
+ void get().refreshTrafficTotals();
+ const unsubscribeSnapshot = await desktop.subscribe((snapshot) => {
+ set({
+ snapshot,
+ actionPending: snapshot.busy != null,
+ });
+ void get().refreshTrafficTotals();
+ });
const unsubscribeUpdate = await desktop.subscribeUpdateProgress(
(progress) => {
get().applyUpdateProgress(progress);
@@ -121,8 +177,19 @@ export const useAppStore = create((set, get) => ({
},
toggleConnection: async () => {
const snapshot = get().snapshot;
- if (!snapshot) return;
- set({ actionPending: true, error: null });
+ if (!snapshot || controlsLocked(snapshot, get().actionPending)) return;
+ set({ actionPending: true, error: null, installGuide: null });
+ const timeout = window.setTimeout(() => {
+ const current = get().snapshot;
+ if (get().actionPending) {
+ set({
+ actionPending: false,
+ installingId: null,
+ error: "The connection operation timed out.",
+ snapshot: current ? { ...current, busy: null } : current,
+ });
+ }
+ }, ACTION_TIMEOUT_MS);
try {
if (
snapshot.phase === "running" ||
@@ -131,13 +198,21 @@ export const useAppStore = create((set, get) => ({
) {
await desktop.stop();
} else {
+ await ensureRequiredServices(get, set);
await desktop.start();
}
} catch (error) {
- set({ actionPending: false, error: message(error) });
+ set({
+ actionPending: false,
+ installingId: null,
+ error: message(error),
+ });
+ } finally {
+ window.clearTimeout(timeout);
}
},
pauseConnection: async () => {
+ if (controlsLocked(get().snapshot, get().actionPending)) return;
set({ actionPending: true, error: null });
try {
await desktop.pause();
@@ -146,6 +221,7 @@ export const useAppStore = create((set, get) => ({
}
},
resumeConnection: async () => {
+ if (controlsLocked(get().snapshot, get().actionPending)) return;
set({ actionPending: true, error: null });
try {
await desktop.resume();
@@ -211,6 +287,9 @@ export const useAppStore = create((set, get) => ({
}
},
syncCloudRules: async () => {
+ if (get().actionPending) {
+ return;
+ }
set({ actionPending: true, error: null });
try {
const cloudRules = await desktop.syncCloudRules();
@@ -219,12 +298,29 @@ export const useAppStore = create((set, get) => ({
set({ actionPending: false, error: message(error) });
}
},
+ refreshTrafficTotals: async () => {
+ if (get().trafficRefreshing) {
+ return;
+ }
+ set({ trafficRefreshing: true });
+ try {
+ const trafficTotals = await desktop.getTrafficTotals();
+ set({ trafficTotals, trafficRefreshing: false });
+ } catch {
+ set({ trafficRefreshing: false });
+ }
+ },
refreshNetworkStatus: async () => {
+ if (get().networkRefreshing) {
+ return;
+ }
+ set({ networkRefreshing: true });
try {
const networkStatus = await desktop.getNetworkStatus();
- set({ networkStatus });
+ set({ networkStatus, networkRefreshing: false });
} catch (error) {
set({
+ networkRefreshing: false,
networkStatus: {
state: "offline",
public_ip: null,
@@ -274,6 +370,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",
@@ -299,6 +404,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: {
@@ -354,6 +468,9 @@ function updateStatusToProgress(status: UpdateStatus): UpdateProgress {
percent: null,
version: null,
error: null,
+ app_available: false,
+ rules_available: false,
+ thirdparty_available: false,
};
}
return {
@@ -361,5 +478,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/apps/desktop/src/vite-env.d.ts b/apps/desktop/src/vite-env.d.ts
index f36c801..0f240f2 100644
--- a/apps/desktop/src/vite-env.d.ts
+++ b/apps/desktop/src/vite-env.d.ts
@@ -12,6 +12,8 @@ declare global {
interface Window {
__TAURI_INTERNALS__?: unknown;
__BIFLOW_RESET_MOCK?: () => void;
+ __BIFLOW_STAGE_SEEN?: string[];
+ __BIFLOW_STAGE_STOP?: () => void;
}
}
diff --git a/crates/iran-split-core/src/lib.rs b/crates/iran-split-core/src/lib.rs
index 7ab05e4..0b58e96 100644
--- a/crates/iran-split-core/src/lib.rs
+++ b/crates/iran-split-core/src/lib.rs
@@ -100,6 +100,7 @@ pub enum ErrorCode {
RouteTestFailed,
UpdateSignatureInvalid,
OperationInProgress,
+ OperationTimeout,
OperationCancelled,
Internal,
}
@@ -125,10 +126,71 @@ pub struct AppError {
pub correlation_id: Uuid,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum LifecycleBusy {
+ Connecting,
+ Disconnecting,
+ Pausing,
+ Resuming,
+ Reconciling,
+}
+
+impl LifecycleBusy {
+ const fn as_kind(self) -> OperationKind {
+ match self {
+ Self::Reconciling => OperationKind::Reconcile,
+ Self::Connecting => OperationKind::Start,
+ Self::Disconnecting => OperationKind::Stop,
+ Self::Pausing => OperationKind::Pause,
+ Self::Resuming => OperationKind::Resume,
+ }
+ }
+}
+
+#[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,
@@ -147,6 +209,8 @@ impl Default for StackSnapshot {
Self {
revision: 0,
phase: StackPhase::Uninitialized,
+ busy: None,
+ operation_stage: None,
operation_id: None,
helper: ComponentStatus::default(),
hiddify: ComponentStatus::default(),
@@ -261,6 +325,10 @@ pub enum CoreError {
TunCleanupFailed(String),
#[error("operation was cancelled")]
Cancelled,
+ #[error("another connection operation is already in progress")]
+ OperationInProgress,
+ #[error("operation timed out")]
+ OperationTimeout,
#[error("operation queue is unavailable")]
QueueUnavailable,
#[error("platform operation failed: {0}")]
@@ -337,6 +405,18 @@ impl CoreError {
true,
Some(Remediation::Retry),
),
+ Self::OperationInProgress => (
+ ErrorCode::OperationInProgress,
+ "errors.operationInProgress",
+ true,
+ Some(Remediation::Retry),
+ ),
+ Self::OperationTimeout => (
+ ErrorCode::OperationTimeout,
+ "errors.operationTimeout",
+ true,
+ Some(Remediation::Retry),
+ ),
Self::QueueUnavailable | Self::Platform(_) => (
ErrorCode::Internal,
"errors.internal",
@@ -394,9 +474,43 @@ enum OperationKind {
Resume,
}
+impl OperationKind {
+ const fn busy(self) -> LifecycleBusy {
+ match self {
+ Self::Reconcile => LifecycleBusy::Reconciling,
+ Self::Start => LifecycleBusy::Connecting,
+ Self::Stop => LifecycleBusy::Disconnecting,
+ Self::Pause => LifecycleBusy::Pausing,
+ 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)]
+struct OperationTimeouts {
+ start: Duration,
+ stop: Duration,
+}
+
+impl Default for OperationTimeouts {
+ fn default() -> Self {
+ Self {
+ start: Duration::from_secs(120),
+ stop: Duration::from_secs(45),
+ }
+ }
+}
+
#[derive(Debug, Clone)]
struct OperationRecord {
- kind: OperationKind,
cancel: CancellationToken,
}
@@ -413,6 +527,7 @@ pub struct Engine {
queue: mpsc::Sender,
operations: Mutex>,
pending: Mutex>,
+ timeouts: OperationTimeouts,
}
impl std::fmt::Debug for Engine {
@@ -431,6 +546,14 @@ impl Engine {
/// engine outside an entered Tokio context without panicking.
#[must_use]
pub fn new(backend: Arc, runtime: &tokio::runtime::Handle) -> Arc {
+ Self::construct(backend, runtime, OperationTimeouts::default())
+ }
+
+ fn construct(
+ backend: Arc,
+ runtime: &tokio::runtime::Handle,
+ timeouts: OperationTimeouts,
+ ) -> Arc {
let (queue, receiver) = mpsc::channel(16);
let (snapshots, _) = watch::channel(StackSnapshot::default());
let engine = Arc::new(Self {
@@ -439,11 +562,23 @@ impl Engine {
queue,
operations: Mutex::new(HashMap::new()),
pending: Mutex::new(HashMap::new()),
+ timeouts,
});
runtime.spawn(Self::worker(Arc::clone(&engine), receiver));
engine
}
+ #[cfg(test)]
+ #[must_use]
+ pub fn new_with_timeouts(
+ backend: Arc,
+ runtime: &tokio::runtime::Handle,
+ start: Duration,
+ stop: Duration,
+ ) -> Arc {
+ Self::construct(backend, runtime, OperationTimeouts { start, stop })
+ }
+
#[must_use]
pub fn snapshot(&self) -> StackSnapshot {
self.snapshots.borrow().clone()
@@ -491,7 +626,12 @@ impl Engine {
/// Returns [`CoreError::QueueUnavailable`] when the operation worker is no
/// longer available.
pub async fn start_stack(&self) -> Result {
+ if self.snapshot().busy.is_some() && self.snapshot().busy != Some(LifecycleBusy::Connecting)
+ {
+ return Err(CoreError::OperationInProgress);
+ }
if self.snapshot().phase == StackPhase::Running {
+ self.release_if_idle(OperationKind::Start).await;
return Ok(OperationAccepted {
operation_id: Uuid::new_v4(),
already_complete: true,
@@ -507,13 +647,17 @@ impl Engine {
/// Returns [`CoreError::QueueUnavailable`] when the operation worker is no
/// longer available.
pub async fn stop_stack(&self) -> Result {
+ if self.snapshot().busy.is_some()
+ && self.snapshot().busy != Some(LifecycleBusy::Disconnecting)
+ {
+ return Err(CoreError::OperationInProgress);
+ }
if self.snapshot().phase == StackPhase::Stopped && self.operations.lock().await.is_empty() {
return Ok(OperationAccepted {
operation_id: Uuid::new_v4(),
already_complete: true,
});
}
- self.cancel_inflight_starts().await;
self.accept(OperationKind::Stop).await
}
@@ -524,22 +668,19 @@ impl Engine {
/// Returns [`CoreError::QueueUnavailable`] when the operation worker is no
/// longer available.
pub async fn pause_stack(&self) -> Result {
- match self.snapshot().phase {
- StackPhase::Paused if self.operations.lock().await.is_empty() => {
- return Ok(OperationAccepted {
- operation_id: Uuid::new_v4(),
- already_complete: true,
- });
- }
- StackPhase::Stopped | StackPhase::Uninitialized => {
- return Ok(OperationAccepted {
- operation_id: Uuid::new_v4(),
- already_complete: true,
- });
- }
- _ => {}
+ if self.snapshot().busy.is_some() && self.snapshot().busy != Some(LifecycleBusy::Pausing) {
+ return Err(CoreError::OperationInProgress);
+ }
+ if matches!(
+ self.snapshot().phase,
+ StackPhase::Paused | StackPhase::Stopped | StackPhase::Uninitialized
+ ) && self.operations.lock().await.is_empty()
+ {
+ return Ok(OperationAccepted {
+ operation_id: Uuid::new_v4(),
+ already_complete: true,
+ });
}
- self.cancel_inflight_starts().await;
self.accept(OperationKind::Pause).await
}
@@ -550,6 +691,9 @@ impl Engine {
/// Returns [`CoreError::QueueUnavailable`] when the operation worker is no
/// longer available.
pub async fn resume_stack(&self) -> Result {
+ if self.snapshot().busy.is_some() && self.snapshot().busy != Some(LifecycleBusy::Resuming) {
+ return Err(CoreError::OperationInProgress);
+ }
if self.snapshot().phase == StackPhase::Running {
return Ok(OperationAccepted {
operation_id: Uuid::new_v4(),
@@ -565,13 +709,19 @@ impl Engine {
self.accept(OperationKind::Resume).await
}
- async fn cancel_inflight_starts(&self) {
- let operations = self.operations.lock().await;
- for operation in operations.values() {
- if operation.kind == OperationKind::Start {
- operation.cancel.cancel();
- }
- }
+ /// Reserves the shared lifecycle lock before a long prepare step.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`CoreError::OperationInProgress`] when another connection
+ /// operation is already reserved or queued.
+ pub async fn reserve_lifecycle(&self, busy: LifecycleBusy) -> Result<(), CoreError> {
+ self.reserve(busy.as_kind()).await
+ }
+
+ /// Clears a reservation that never reached the worker queue.
+ pub async fn release_lifecycle(&self, busy: LifecycleBusy) {
+ self.release_if_idle(busy.as_kind()).await;
}
/// Queues a stop followed by a start.
@@ -596,7 +746,101 @@ impl Engine {
}
}
+ async fn reserve(&self, kind: OperationKind) -> Result<(), CoreError> {
+ let pending = self.pending.lock().await;
+ if let Some(operation_id) = pending.get(&kind) {
+ info!(
+ event = "operation.deduplicated",
+ section = "engine",
+ initiator = "operation_queue",
+ cause = "matching_operation_pending",
+ trace_route = "caller->engine->reserve",
+ operation_id = %operation_id,
+ kind = ?kind,
+ "existing operation reservation reused"
+ );
+ return Ok(());
+ }
+ if !pending.is_empty() {
+ return Err(CoreError::OperationInProgress);
+ }
+ let current = self.snapshot().busy;
+ if let Some(busy) = current {
+ if busy != kind.busy() {
+ return Err(CoreError::OperationInProgress);
+ }
+ return Ok(());
+ }
+ drop(pending);
+ self.update(|snapshot| {
+ snapshot.busy = Some(kind.busy());
+ snapshot.operation_stage = Some(kind.initial_stage());
+ });
+ info!(
+ event = "operation.reserved",
+ section = "engine",
+ initiator = "operation_queue",
+ cause = "accepted",
+ trace_route = "caller->engine->reserve",
+ kind = ?kind,
+ busy = ?kind.busy(),
+ "lifecycle lock reserved"
+ );
+ Ok(())
+ }
+
+ async fn release_if_idle(&self, kind: OperationKind) {
+ if !self.pending.lock().await.is_empty() {
+ return;
+ }
+ self.update(|snapshot| {
+ if snapshot.busy == Some(kind.busy()) && snapshot.operation_id.is_none() {
+ snapshot.busy = None;
+ snapshot.operation_stage = None;
+ }
+ });
+ }
+
+ fn timeout_for(&self, kind: OperationKind) -> Duration {
+ match kind {
+ OperationKind::Stop | OperationKind::Pause => self.timeouts.stop,
+ OperationKind::Reconcile | OperationKind::Start | OperationKind::Resume => {
+ self.timeouts.start
+ }
+ }
+ }
+
+ async fn execute_item(&self, item: &WorkItem) -> Result<(), CoreError> {
+ let work = async {
+ match item.kind {
+ OperationKind::Reconcile => self.run_reconcile(item.id, &item.cancel).await,
+ OperationKind::Start => self.run_start(item.id, &item.cancel).await,
+ OperationKind::Stop => self.run_stop(item.id).await,
+ OperationKind::Pause => self.run_pause(item.id).await,
+ OperationKind::Resume => self.run_resume(item.id, &item.cancel).await,
+ }
+ };
+ if let Ok(result) = tokio::time::timeout(self.timeout_for(item.kind), work).await {
+ result
+ } else {
+ item.cancel.cancel();
+ warn!(
+ event = "operation.timed_out",
+ section = "engine",
+ initiator = "operation_worker",
+ cause = "timeout",
+ trace_id = %item.id,
+ trace_route = "operation_queue->worker->timeout",
+ operation_id = %item.id,
+ kind = ?item.kind,
+ "operation exceeded its time budget"
+ );
+ Err(CoreError::OperationTimeout)
+ }
+ }
+
async fn accept(&self, kind: OperationKind) -> Result {
+ self.reserve(kind).await?;
let mut pending = self.pending.lock().await;
if let Some(operation_id) = pending.get(&kind) {
info!(
@@ -619,7 +863,6 @@ impl Engine {
self.operations.lock().await.insert(
operation_id,
OperationRecord {
- kind,
cancel: cancel.clone(),
},
);
@@ -677,13 +920,7 @@ impl Engine {
kind = ?item.kind,
"operation started"
);
- let result = match item.kind {
- OperationKind::Reconcile => engine.run_reconcile(item.id, &item.cancel).await,
- OperationKind::Start => engine.run_start(item.id, &item.cancel).await,
- OperationKind::Stop => engine.run_stop(item.id).await,
- OperationKind::Pause => engine.run_pause(item.id).await,
- OperationKind::Resume => engine.run_resume(item.id, &item.cancel).await,
- };
+ let result = engine.execute_item(&item).await;
if let Err(error) = result {
if !matches!(error, CoreError::Cancelled) {
error!(
@@ -711,7 +948,11 @@ impl Engine {
});
}
}
- engine.update(|snapshot| snapshot.operation_id = None);
+ 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);
info!(
@@ -811,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);
});
@@ -821,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);
@@ -839,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);
@@ -930,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!(
@@ -944,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() {
@@ -960,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",
@@ -972,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() {
@@ -1042,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);
@@ -1064,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;
});
@@ -1454,18 +1723,80 @@ mod tests {
}
#[tokio::test]
- async fn stop_cancels_an_in_progress_start_then_cleans() {
+ async fn conflicting_operations_are_rejected_while_start_is_busy() {
let backend = Arc::new(FakeBackend::default());
backend.slow_hiddify.store(true, Ordering::SeqCst);
let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current());
+ let first = engine.start_stack().await.expect("start accepted");
+ let duplicate = engine.start_stack().await.expect("duplicate start");
+ assert_eq!(first.operation_id, duplicate.operation_id);
+ assert_eq!(engine.snapshot().busy, Some(LifecycleBusy::Connecting));
+ assert!(matches!(
+ engine.stop_stack().await,
+ Err(CoreError::OperationInProgress)
+ ));
+ assert!(matches!(
+ engine.pause_stack().await,
+ Err(CoreError::OperationInProgress)
+ ));
+ engine
+ .wait_for_phase(StackPhase::Running, Duration::from_secs(3))
+ .await
+ .expect("running");
+ assert_eq!(engine.snapshot().busy, None);
+ assert_eq!(backend.starts.load(Ordering::SeqCst), 1);
+ }
+
+ #[tokio::test]
+ async fn operation_timeout_clears_the_lifecycle_lock() {
+ let backend = Arc::new(FakeBackend::default());
+ backend.slow_hiddify.store(true, Ordering::SeqCst);
+ let engine = Engine::new_with_timeouts(
+ Arc::clone(&backend),
+ &tokio::runtime::Handle::current(),
+ Duration::from_millis(40),
+ Duration::from_secs(1),
+ );
engine.start_stack().await.expect("start accepted");
- tokio::time::sleep(Duration::from_millis(20)).await;
- engine.stop_stack().await.expect("stop accepted");
+ let mut receiver = engine.subscribe();
+ tokio::time::timeout(Duration::from_secs(2), async {
+ loop {
+ let snapshot = receiver.borrow().clone();
+ if snapshot.phase == StackPhase::Error && snapshot.busy.is_none() {
+ return;
+ }
+ receiver.changed().await.expect("snapshot update");
+ }
+ })
+ .await
+ .expect("timeout recovered");
+ let error = engine.snapshot().last_error.expect("timeout error");
+ assert_eq!(error.code, ErrorCode::OperationTimeout);
engine
- .wait_for_phase(StackPhase::Stopped, Duration::from_secs(2))
+ .stop_stack()
.await
- .expect("stopped");
- assert!(!backend.tun.load(Ordering::SeqCst));
+ .expect("controls recover after timeout");
+ }
+
+ #[tokio::test]
+ async fn reserve_blocks_tray_like_conflicting_entry_points() {
+ let backend = Arc::new(FakeBackend::default());
+ backend.slow_hiddify.store(true, Ordering::SeqCst);
+ let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current());
+ engine
+ .reserve_lifecycle(LifecycleBusy::Connecting)
+ .await
+ .expect("reserved");
+ assert!(matches!(
+ engine.reserve_lifecycle(LifecycleBusy::Disconnecting).await,
+ Err(CoreError::OperationInProgress)
+ ));
+ assert!(matches!(
+ engine.pause_stack().await,
+ Err(CoreError::OperationInProgress)
+ ));
+ engine.release_lifecycle(LifecycleBusy::Connecting).await;
+ assert_eq!(engine.snapshot().busy, None);
}
#[tokio::test]
@@ -1516,6 +1847,50 @@ 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/crates/iran-split-mihomo/src/lib.rs b/crates/iran-split-mihomo/src/lib.rs
index 1f7d952..5c79d86 100644
--- a/crates/iran-split-mihomo/src/lib.rs
+++ b/crates/iran-split-mihomo/src/lib.rs
@@ -533,6 +533,22 @@ impl ControllerClient {
.await?)
}
+ /// Reads session upload and download totals from the controller.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error when the controller request or response decoding fails.
+ pub async fn connection_totals(&self) -> Result<(u64, u64), MihomoError> {
+ let snapshot: ConnectionsSnapshot = self
+ .get("/connections")
+ .send()
+ .await?
+ .error_for_status()?
+ .json()
+ .await?;
+ Ok((snapshot.upload_total, snapshot.download_total))
+ }
+
/// Reads the active Mihomo configuration from the controller.
///
/// # Errors
@@ -711,6 +727,14 @@ pub struct ExitIpResponse {
pub ip: String,
}
+#[derive(Debug, Clone, Deserialize)]
+struct ConnectionsSnapshot {
+ #[serde(rename = "uploadTotal", default)]
+ upload_total: u64,
+ #[serde(rename = "downloadTotal", default)]
+ download_total: u64,
+}
+
/// Resolves the public egress IP through the configured Hiddify SOCKS proxy.
///
/// # Errors
@@ -842,6 +866,17 @@ mod tests {
);
}
+ #[test]
+ fn connection_totals_read_clash_field_names() {
+ let parsed: ConnectionsSnapshot = serde_json::from_value(serde_json::json!({
+ "uploadTotal": 11,
+ "downloadTotal": 29
+ }))
+ .expect("connections snapshot");
+ assert_eq!(parsed.upload_total, 11);
+ assert_eq!(parsed.download_total, 29);
+ }
+
#[test]
fn empty_custom_providers_count_as_ready() {
let summary = summarize_rule_providers(&serde_json::json!({
diff --git a/crates/iran-split-rules/src/cloud.rs b/crates/iran-split-rules/src/cloud.rs
index ff95ba6..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(())),
}
}
@@ -296,6 +319,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
@@ -304,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",
@@ -317,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,
@@ -352,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,
@@ -382,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"
@@ -414,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)
}
@@ -758,7 +855,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]
@@ -807,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/0009-user-facing-readme.md b/docs/adr/0009-user-facing-readme.md
index 7117c8f..27d5d5f 100644
--- a/docs/adr/0009-user-facing-readme.md
+++ b/docs/adr/0009-user-facing-readme.md
@@ -13,8 +13,12 @@ answers to common questions.
## Decision
`README.md` is the product landing page. It leads with the BiFlow tagline
-**Right traffic. Right route.**, then description, how it works, architecture,
-develop, and FAQ. Agent rules and crate-level lessons stay in `AGENTS.md`.
+**Right traffic. Right route.**, then desktop and mobile screenshots, a
+features list, description, how it works, architecture, develop, and FAQ.
+Agent rules and crate-level lessons stay in `AGENTS.md`.
+
+Committed shots live in `docs/screenshots/`. Refresh them with
+`BIFLOW_CAPTURE_README=1 pnpm exec playwright test e2e/readme-screenshots.spec.ts`.
## Consequences
@@ -22,3 +26,5 @@ develop, and FAQ. Agent rules and crate-level lessons stay in `AGENTS.md`.
the user-facing sections.
- Architecture diagrams in the README stay aligned with the helper IPC and
split-routing ADRs.
+- The features list and screenshot files are covered by the README contract
+ test so a landing-page edit cannot drop them.
diff --git a/docs/adr/0023-basic-mode-persistence.md b/docs/adr/0023-basic-mode-persistence.md
index 48f0f17..23bb454 100644
--- a/docs/adr/0023-basic-mode-persistence.md
+++ b/docs/adr/0023-basic-mode-persistence.md
@@ -12,12 +12,14 @@ pause, resume, and disconnect controls. The preference must survive restarts.
## Decision
- Store the UI mode under the versioned localStorage key `biflow-ui-mode-v1`.
-- Default missing or invalid values to **Advanced** so existing installs keep
- every current screen and capability until they opt into Basic.
+- Default missing or invalid values to **Basic** so a first launch shows the
+ connect-only dashboard. Existing installs that already stored Advanced keep
+ that preference. Connect still installs missing services in Basic (ADR 0044).
- 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
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/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/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/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/0043-persistent-traffic-totals.md b/docs/adr/0043-persistent-traffic-totals.md
new file mode 100644
index 0000000..aeb213f
--- /dev/null
+++ b/docs/adr/0043-persistent-traffic-totals.md
@@ -0,0 +1,26 @@
+# ADR 0043: Persistent traffic totals
+
+## Status
+
+Accepted
+
+## Context
+
+The status bar showed no sent or received counters. Mihomo's `/connections`
+totals reset whenever the core restarts, so displaying that session snapshot
+alone would zero the bar on every disconnect or reconnect.
+
+## Decision
+
+- Read Clash `uploadTotal` / `downloadTotal` from the loopback controller while
+ the stack is `running` or `degraded`.
+- Fold each session into a lifetime file (`traffic-totals.json`) so a later
+ session adds to the previous total. A mid-session Mihomo restart that drops
+ the counters also folds the previous session first.
+- The status bar formats lifetime bytes with kibibyte-based units through
+ pebibytes. A failed probe keeps the last displayed total.
+
+## Consequences
+
+- Disconnect and reconnect no longer reset the counters.
+- The file lives in the per-user data directory and is not a diagnostic secret.
diff --git a/docs/adr/0044-connect-installs-dependencies.md b/docs/adr/0044-connect-installs-dependencies.md
new file mode 100644
index 0000000..8b16954
--- /dev/null
+++ b/docs/adr/0044-connect-installs-dependencies.md
@@ -0,0 +1,28 @@
+# ADR 0044: Connect installs required services
+
+## Status
+
+Accepted
+
+## Context
+
+Connect previously started the stack immediately. A missing helper, Hiddify
+binary, or Mihomo binary then failed mid-start and left the operator to press
+separate Install buttons.
+
+## Decision
+
+- Before `start_stack` (UI Connect, tray Connect, and the Tauri command),
+ install missing services in a fixed order: helper, then Hiddify, then Mihomo.
+- Each step is idempotent. Already-present services are skipped. A failed step
+ aborts before the stack starts and keeps the last useful error or install
+ guide.
+- The UI sets `installingId` so the matching Install control shows progress
+ while Connect is preparing.
+
+## Consequences
+
+- First-run Connect in Basic or Advanced can finish setup without a separate
+ install pass.
+- Helper install still needs an interactive polkit or UAC prompt; that failure
+ is reported instead of hanging the start path.
diff --git a/docs/adr/0045-square-connection-glow.md b/docs/adr/0045-square-connection-glow.md
new file mode 100644
index 0000000..f8c90c4
--- /dev/null
+++ b/docs/adr/0045-square-connection-glow.md
@@ -0,0 +1,25 @@
+# ADR 0045: Square connection glow
+
+## Status
+
+Accepted
+
+## Context
+
+The connected-state ring used a 2px border and `0.5rem` radius. That left a
+visible gap at the window corners and looked thin on scaled Linux and Windows
+displays.
+
+## Decision
+
+- Keep the glow on a `pointer-events: none` overlay (`::after`), not the
+ shell border, so layout size does not change.
+- Use `border-radius: 0` so the ring meets the window edges.
+- Increase the border to 3px and strengthen the inset/outer shadows. Lengths
+ stay in CSS pixels so 125%/150% display scaling thickens the ring with the
+ rest of the UI.
+
+## Consequences
+
+- Playwright still asserts `data-connection-glow` rather than computed colour.
+- Reduced-motion still disables the pulse.
diff --git a/docs/adr/0046-persist-window-size.md b/docs/adr/0046-persist-window-size.md
new file mode 100644
index 0000000..a1ee8db
--- /dev/null
+++ b/docs/adr/0046-persist-window-size.md
@@ -0,0 +1,24 @@
+# ADR 0046: Persist window size
+
+## Status
+
+Accepted
+
+## Context
+
+The main window was locked at 1120×760. Operators on smaller or larger
+displays could not keep a chosen size across launches.
+
+## Decision
+
+- Make the window resizable with a 390×640 minimum and a 1120×760 default.
+- Persist logical width/height to `window-size.json` on every resize.
+- Restore on startup, clamped to the current monitor work area so a saved
+ size cannot open off-screen or below the supported minimum when the
+ display allows it.
+
+## Consequences
+
+- Mobile, tablet, and small-desktop viewports can be used in the packaged
+ app, not only in the mock browser.
+- A work area smaller than 390×640 wins so the window stays on the display.
diff --git a/docs/adr/0047-three-viewport-layouts.md b/docs/adr/0047-three-viewport-layouts.md
new file mode 100644
index 0000000..053a531
--- /dev/null
+++ b/docs/adr/0047-three-viewport-layouts.md
@@ -0,0 +1,23 @@
+# ADR 0047: Three representative viewport layouts
+
+## Status
+
+Accepted
+
+## Context
+
+The shell now resizes. Mobile, tablet, and small-desktop sizes need automated
+coverage so bottom navigation, the status bar, and page scroll stay correct.
+
+## Decision
+
+- Treat 390×844 as mobile: bottom navigation, no hamburger, status bar below
+ the nav.
+- Treat 768×1024 as tablet and 1024×768 as small desktop: sidebar navigation.
+- Playwright records a screenshot of each size and asserts no horizontal
+ overflow and no nav/status overlap.
+
+## Consequences
+
+- Visual review of the three screenshots is part of the done gate for layout
+ work.
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/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/0050-connection-operation-lock.md b/docs/adr/0050-connection-operation-lock.md
new file mode 100644
index 0000000..77b9841
--- /dev/null
+++ b/docs/adr/0050-connection-operation-lock.md
@@ -0,0 +1,32 @@
+# ADR 0050: Connection operation lock
+
+## Status
+
+Accepted
+
+## Context
+
+Connect, Disconnect, Pause, and Resume could be started from the dashboard,
+Basic mode, and the tray at the same time. The engine only deduplicated the
+same operation kind, so a stop could queue while a start was still running and
+the UI re-enabled on every intermediate snapshot.
+
+## Decision
+
+- Publish `StackSnapshot.busy` as `connecting`, `disconnecting`, `pausing`,
+ `resuming`, or `reconciling`.
+- `Engine::accept` holds one shared reservation. A second kind returns
+ `OperationInProgress`. The same kind is reused.
+- Reserve the lock before Connect's dependency install so tray and UI cannot
+ start a second prepare step.
+- Bound each worker item with a timeout (120s start/resume, 45s stop/pause).
+ Timeout cancels the token, sets `Error`, and clears `busy`.
+- Disable the tray Connect/Disconnect and Pause/Resume items while `busy` is
+ set. Quit stays available.
+- The React store ignores a second click and keeps controls locked until
+ `busy` clears, a command error, or a 130s watchdog.
+
+## Consequences
+
+- Disconnect no longer cancels an in-flight Connect; use Cancel operation.
+- Restart still waits for Stopped, then starts, so it never overlaps.
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/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/0053-connect-button-glow.md b/docs/adr/0053-connect-button-glow.md
new file mode 100644
index 0000000..319076f
--- /dev/null
+++ b/docs/adr/0053-connect-button-glow.md
@@ -0,0 +1,26 @@
+# ADR 0053: Connect button availability glow
+
+## Status
+
+Accepted
+
+## Context
+
+The window ring (ADR 0045) shows a live stack. Operators also need a cue that
+Connect itself is the next action, without fighting the in-button progress
+fill added in ADR 0052.
+
+## Decision
+
+- Apply `.connect-button-glow` only to the Connect control, and only while it
+ is enabled and idle.
+- Animate `box-shadow` on the button. The progress fill stays on an inner
+ clipped layer so the two motions do not share the same property.
+- Disable the class as soon as the control is disabled or processing.
+- Honor `prefers-reduced-motion` with a static outline and no pulse.
+- Linux and Windows share the same CSS.
+
+## Consequences
+
+- Playwright asserts `data-connect-glow` rather than computed shadow colour.
+- Resume and Disconnect stay un-glowed so Connect remains the invitation.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 7861c55..1155de8 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -42,3 +42,18 @@ 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 |
+| [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 |
+| [0043](./0043-persistent-traffic-totals.md) | Persistent traffic totals | Accepted |
+| [0044](./0044-connect-installs-dependencies.md) | Connect installs required services | Accepted |
+| [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 |
+| [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 |
+| [0053](./0053-connect-button-glow.md) | Connect button availability glow | 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..6cad9a4
--- /dev/null
+++ b/docs/considering/desktop-sixteen-task-plan.md
@@ -0,0 +1,49 @@
+# 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.
+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** — Done: `operation_stage` on the snapshot, standalone card removed, fill and stage label live on the active connection button.
+20. **Connect glow** — Done: `.connect-button-glow` on idle enabled Connect only; off while processing; reduced-motion is static.
+
+## 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/docs/considering/viewports/mobile-390x844.png b/docs/considering/viewports/mobile-390x844.png
new file mode 100644
index 0000000..7b11838
Binary files /dev/null and b/docs/considering/viewports/mobile-390x844.png differ
diff --git a/docs/considering/viewports/small-desktop-1024x768.png b/docs/considering/viewports/small-desktop-1024x768.png
new file mode 100644
index 0000000..809ba36
Binary files /dev/null and b/docs/considering/viewports/small-desktop-1024x768.png differ
diff --git a/docs/considering/viewports/tablet-768x1024.png b/docs/considering/viewports/tablet-768x1024.png
new file mode 100644
index 0000000..94efc71
Binary files /dev/null and b/docs/considering/viewports/tablet-768x1024.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
new file mode 100644
index 0000000..af08ae0
Binary files /dev/null and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/mobile.png b/docs/screenshots/mobile.png
new file mode 100644
index 0000000..aed263d
Binary files /dev/null and b/docs/screenshots/mobile.png differ
diff --git a/e2e/primary-flows.spec.ts b/e2e/primary-flows.spec.ts
index 0badee7..e3ee58b 100644
--- a/e2e/primary-flows.spec.ts
+++ b/e2e/primary-flows.spec.ts
@@ -1,13 +1,23 @@
import { expect, test, type Page } from "@playwright/test";
-async function openFresh(page: Page) {
+async function openFresh(page: Page, mode: "basic" | "advanced" = "advanced") {
await page.goto("/");
await page.waitForFunction(
"typeof window.__BIFLOW_RESET_MOCK === 'function'",
);
- await page.evaluate("window.__BIFLOW_RESET_MOCK()");
+ await page.evaluate(
+ ([nextMode]) => {
+ window.__BIFLOW_RESET_MOCK?.();
+ localStorage.setItem("biflow-ui-mode-v1", nextMode);
+ },
+ [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) {
@@ -42,6 +52,8 @@ test.describe("primary BiFlow flows", () => {
await expect(statusBar).toContainText("Internet connected");
await expect(statusBar).toContainText("198.51.100.24");
await expect(statusBar).toContainText("🇮🇷");
+ await expect(statusBar).toContainText("Sent: 1.00 MiB");
+ await expect(statusBar).toContainText("Received: 2.00 MiB");
await expect(page.getByText("unknown", { exact: true })).toHaveCount(0);
const installButtons = page.getByRole("button", {
@@ -58,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();
@@ -87,6 +99,76 @@ test.describe("primary BiFlow flows", () => {
await expect(page.locator(".traffic-flow-route")).toHaveCount(0);
});
+ test("disables lifecycle controls after the first Connect click", async ({
+ page,
+ }) => {
+ await openFresh(page);
+ const installButtons = page.getByRole("button", {
+ name: "Install",
+ exact: true,
+ });
+ await installButtons.nth(0).click();
+ await page.getByRole("button", { name: "Install", exact: true }).click();
+ const connect = page.locator("[data-connection-action='connect']");
+ await expect(connect).toHaveAttribute("data-connect-glow", "available");
+ await page.evaluate(() => {
+ const seen: string[] = [];
+ const record = () => {
+ const label = document.querySelector(
+ "[data-connection-action='connect'] .connection-action-label",
+ );
+ const text = label?.textContent?.trim();
+ if (text && seen.at(-1) !== text) {
+ seen.push(text);
+ }
+ };
+ record();
+ const observer = new MutationObserver(record);
+ observer.observe(document.body, {
+ subtree: true,
+ childList: true,
+ characterData: true,
+ });
+ window.__BIFLOW_STAGE_SEEN = seen;
+ window.__BIFLOW_STAGE_STOP = () => observer.disconnect();
+ });
+ await connect.click();
+ await expect(connect).toBeDisabled();
+ await expect(connect).toHaveAttribute("data-processing", "true");
+ await expect(connect).toHaveAttribute("data-connect-glow", "off");
+ await connect.click({ force: true });
+ await expect(
+ page.getByRole("heading", { name: "Protected split routing is active" }),
+ ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Pause" })).toBeEnabled();
+ await expect(
+ page.getByRole("button", { name: "Disconnect" }),
+ ).toBeEnabled();
+ const stages = await page.evaluate(() => {
+ window.__BIFLOW_STAGE_STOP?.();
+ return window.__BIFLOW_STAGE_SEEN ?? [];
+ });
+ expect(stages).toEqual(
+ expect.arrayContaining(["Start Hiddify", "Start Mihomo"]),
+ );
+ });
+
+ test("connect installs missing apps before starting the stack", async ({
+ page,
+ }) => {
+ await openFresh(page);
+ await expect(
+ page.getByRole("button", { name: "Install", exact: true }),
+ ).toHaveCount(2);
+ await connectButton(page).click();
+ await expect(
+ page.getByRole("heading", { name: "Protected split routing is active" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Install", exact: true }),
+ ).toHaveCount(0);
+ });
+
test("installs a missing helper from the advanced dashboard", async ({
page,
}) => {
@@ -184,7 +266,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/);
@@ -278,18 +360,43 @@ test.describe("primary BiFlow flows", () => {
]);
});
+ test("starts in Basic mode on first launch and can return to Advanced", async ({
+ page,
+ }) => {
+ await openFresh(page, "basic");
+ await expect(connectButton(page)).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Direct rules" }),
+ ).toHaveCount(0);
+ await expectNoDocumentOverflow(page);
+
+ await connectButton(page).click();
+ await expect(
+ page.getByRole("heading", { name: "Protected split routing is active" }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Disconnect" }).click();
+
+ await page.getByRole("radio", { name: "Advanced" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Ready when you are" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Direct rules" }),
+ ).toBeVisible();
+ });
+
test("hides advanced chrome in Basic mode and can return to Advanced", async ({
page,
}) => {
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();
@@ -302,7 +409,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(
@@ -313,6 +420,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/e2e/readme-screenshots.spec.ts b/e2e/readme-screenshots.spec.ts
new file mode 100644
index 0000000..fea6034
--- /dev/null
+++ b/e2e/readme-screenshots.spec.ts
@@ -0,0 +1,61 @@
+import { expect, test, type Page } from "@playwright/test";
+import { mkdirSync } from "node:fs";
+import { join } from "node:path";
+
+const screenshotDir = join(process.cwd(), "docs/screenshots");
+
+async function openAdvanced(page: Page) {
+ await page.goto("/");
+ await page.waitForFunction(
+ "typeof window.__BIFLOW_RESET_MOCK === 'function'",
+ );
+ await page.evaluate(() => {
+ window.__BIFLOW_RESET_MOCK?.();
+ localStorage.setItem("biflow-ui-mode-v1", "advanced");
+ });
+ await page.reload();
+ await expect(page.getByRole("radio", { name: "Advanced" })).toBeVisible();
+}
+
+async function connectStack(page: Page) {
+ const installButtons = page.getByRole("button", {
+ name: "Install",
+ exact: true,
+ });
+ const count = await installButtons.count();
+ for (let index = 0; index < count; index += 1) {
+ await page
+ .getByRole("button", { name: "Install", exact: true })
+ .first()
+ .click();
+ }
+ await page.getByRole("button", { name: "Connect", exact: true }).click();
+ await expect(
+ page.getByRole("heading", { name: "Protected split routing is active" }),
+ ).toBeVisible();
+}
+
+test.describe("readme screenshots", () => {
+ test.skip(
+ !process.env.BIFLOW_CAPTURE_README,
+ "set BIFLOW_CAPTURE_README=1 to refresh docs/screenshots",
+ );
+
+ test("captures desktop and mobile product shots", async ({ page }) => {
+ mkdirSync(screenshotDir, { recursive: true });
+ await page.setViewportSize({ width: 1120, height: 760 });
+ await openAdvanced(page);
+ await connectStack(page);
+ await page.screenshot({
+ path: join(screenshotDir, "desktop.png"),
+ animations: "disabled",
+ });
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await expect(page.getByTestId("bottom-nav")).toBeVisible();
+ await page.screenshot({
+ path: join(screenshotDir, "mobile.png"),
+ animations: "disabled",
+ });
+ });
+});
diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts
new file mode 100644
index 0000000..474a3be
--- /dev/null
+++ b/e2e/responsive.spec.ts
@@ -0,0 +1,142 @@
+import { expect, test, type Page } from "@playwright/test";
+import { mkdirSync } from "node:fs";
+import { dirname, join } from "node:path";
+
+const viewports = [
+ { name: "mobile", width: 390, height: 844 },
+ { name: "tablet", width: 768, height: 1024 },
+ { name: "small-desktop", width: 1024, height: 768 },
+] as const;
+
+const screenshotDir = "/opt/cursor/artifacts/screenshots";
+
+async function openAdvanced(page: Page) {
+ await page.goto("/");
+ await page.waitForFunction(
+ "typeof window.__BIFLOW_RESET_MOCK === 'function'",
+ );
+ await page.evaluate(() => {
+ window.__BIFLOW_RESET_MOCK?.();
+ localStorage.setItem("biflow-ui-mode-v1", "advanced");
+ });
+ await page.reload();
+ await expect(page.getByRole("radio", { name: "Advanced" })).toBeVisible();
+}
+
+async function layoutMetrics(page: Page) {
+ return page.evaluate(() => {
+ const shell = document.querySelector(".app-shell");
+ const main = document.querySelector("main");
+ const nav = document.querySelector("[data-testid='bottom-nav']");
+ const sidebar = document.querySelector("[data-testid='sidebar-nav']");
+ const status = document.querySelector("footer[role='status']");
+ const hamburger = [...document.querySelectorAll("button")].some((button) =>
+ /hamburger|menu/i.test(
+ button.getAttribute("aria-label") ?? button.textContent ?? "",
+ ),
+ );
+ const overlap =
+ nav && status
+ ? nav.getBoundingClientRect().bottom >
+ status.getBoundingClientRect().top + 0.5
+ : false;
+ return {
+ hamburger,
+ hasBottomNav: Boolean(nav),
+ hasSidebar: Boolean(
+ sidebar && getComputedStyle(sidebar).display !== "none",
+ ),
+ overflowX:
+ document.documentElement.scrollWidth >
+ document.documentElement.clientWidth,
+ overflowY:
+ document.documentElement.scrollHeight >
+ document.documentElement.clientHeight,
+ shellOverflow: shell ? getComputedStyle(shell).overflow : null,
+ mainBottom: main?.getBoundingClientRect().bottom ?? 0,
+ navTop: nav?.getBoundingClientRect().top ?? null,
+ statusTop: status?.getBoundingClientRect().top ?? 0,
+ overlap,
+ };
+ });
+}
+
+test.describe("responsive viewports", () => {
+ for (const viewport of viewports) {
+ test(`lays out ${viewport.name} ${viewport.width}x${viewport.height}`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({
+ width: viewport.width,
+ height: viewport.height,
+ });
+ await openAdvanced(page);
+ const metrics = await layoutMetrics(page);
+ expect(metrics.hamburger).toBe(false);
+ expect(metrics.overflowX).toBe(false);
+ expect(metrics.overlap).toBe(false);
+ if (viewport.width < 768) {
+ expect(metrics.hasBottomNav).toBe(true);
+ expect(metrics.hasSidebar).toBe(false);
+ expect(metrics.navTop).not.toBeNull();
+ expect(metrics.navTop ?? 0).toBeLessThanOrEqual(metrics.statusTop);
+ } else {
+ expect(metrics.hasBottomNav).toBe(false);
+ expect(metrics.hasSidebar).toBe(true);
+ }
+
+ mkdirSync(screenshotDir, { recursive: true });
+ const file = join(
+ screenshotDir,
+ `${viewport.name}-${viewport.width}x${viewport.height}.png`,
+ );
+ await page.screenshot({ path: file, fullPage: false });
+ expect(dirname(file)).toBe(screenshotDir);
+
+ const pages = [
+ "Direct rules",
+ "Diagnostics",
+ "Settings",
+ "About",
+ "Dashboard",
+ ];
+ for (const name of pages) {
+ await page.getByRole("button", { name }).click();
+ const next = await layoutMetrics(page);
+ expect(next.overflowX, name).toBe(false);
+ 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 c7130d7..da2a249 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "iran-split-desktop-workspace",
- "version": "3.0.1",
+ "version": "3.3.2",
"private": true,
"packageManager": "pnpm@9.0.1",
"engines": {
diff --git a/scripts/build-plan.test.mjs b/scripts/build-plan.test.mjs
index c560fa2..66bf01c 100644
--- a/scripts/build-plan.test.mjs
+++ b/scripts/build-plan.test.mjs
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
-import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, it } from "node:test";
@@ -376,11 +376,22 @@ describe("release artifact names", () => {
it("presents BiFlow to end users with a tagline, architecture, and FAQ", () => {
const readme = readFileSync(join(root, "README.md"), "utf8");
assert.match(readme, /Right traffic\. Right route/);
+ assert.match(readme, /## Features/);
+ assert.match(readme, /docs\/screenshots\/desktop\.png/);
+ assert.match(readme, /docs\/screenshots\/mobile\.png/);
assert.match(readme, /## Description/);
assert.match(readme, /## How it works/);
assert.match(readme, /## Architecture/);
assert.match(readme, /## Develop/);
assert.match(readme, /## FAQ/);
+ assert.ok(
+ existsSync(join(root, "docs/screenshots/desktop.png")),
+ "desktop screenshot",
+ );
+ assert.ok(
+ existsSync(join(root, "docs/screenshots/mobile.png")),
+ "mobile screenshot",
+ );
});
it("requires a green frontend and rust build before a change is done", () => {
diff --git a/scripts/tauri-contract.test.mjs b/scripts/tauri-contract.test.mjs
index ec2fe26..f5170f1 100644
--- a/scripts/tauri-contract.test.mjs
+++ b/scripts/tauri-contract.test.mjs
@@ -54,18 +54,21 @@ describe("Tauri frontend contract", () => {
assert.match(rust, /emit\("update-progress"/);
});
- it("fixes the main window at 1120x760 without resize", () => {
+ it("keeps a resizable main window with a 390x640 minimum", () => {
const config = JSON.parse(
readFileSync(join(root, "src-tauri/tauri.conf.json"), "utf8"),
);
const window = config.app.windows[0];
assert.equal(window.width, 1120);
assert.equal(window.height, 760);
- assert.equal(window.minWidth, 1120);
- assert.equal(window.minHeight, 760);
- assert.equal(window.maxWidth, 1120);
- assert.equal(window.maxHeight, 760);
- assert.equal(window.resizable, false);
+ assert.equal(window.minWidth, 390);
+ assert.equal(window.minHeight, 640);
+ assert.equal(window.maxWidth, undefined);
+ assert.equal(window.maxHeight, undefined);
+ assert.equal(window.resizable, true);
+ const rust = readFileSync(join(root, "src-tauri/src/lib.rs"), "utf8");
+ assert.match(rust, /restore_main_window_size/);
+ assert.match(rust, /persist_main_window_size/);
});
it("assigns the default application icon to the tray builder", () => {
@@ -73,8 +76,14 @@ 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/);
+ assert.match(tray, /fn actions_enabled/);
+ assert.match(rust, /reserve_lifecycle/);
});
it("disables WebKitGTK DMA-BUF rendering before the Linux webview starts", () => {
@@ -272,7 +281,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 +299,13 @@ 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\(/);
+ 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/Cargo.toml b/src-tauri/Cargo.toml
index c634848..871b54e 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -22,6 +22,7 @@ hex.workspace = true
iran-split-config = { path = "../crates/iran-split-config" }
iran-split-core = { path = "../crates/iran-split-core" }
iran-split-ipc = { path = "../crates/iran-split-ipc" }
+iran-split-mihomo = { path = "../crates/iran-split-mihomo" }
iran-split-rules = { path = "../crates/iran-split-rules" }
reqwest.workspace = true
regex.workspace = true
diff --git a/src-tauri/src/connect_prep.rs b/src-tauri/src/connect_prep.rs
new file mode 100644
index 0000000..2756329
--- /dev/null
+++ b/src-tauri/src/connect_prep.rs
@@ -0,0 +1,73 @@
+use iran_split_core::ComponentPhase;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ConnectRequirement {
+ Helper,
+ Hiddify,
+ Mihomo,
+}
+
+impl ConnectRequirement {
+ #[must_use]
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Helper => "helper",
+ Self::Hiddify => "hiddify",
+ Self::Mihomo => "mihomo",
+ }
+ }
+}
+
+#[must_use]
+pub fn helper_is_ready(phase: ComponentPhase) -> bool {
+ !matches!(phase, ComponentPhase::Unavailable | ComponentPhase::Error)
+}
+
+#[must_use]
+pub fn missing_requirements(
+ helper_ready: bool,
+ hiddify_installed: bool,
+ mihomo_installed: bool,
+) -> Vec {
+ let mut missing = Vec::new();
+ if !helper_ready {
+ missing.push(ConnectRequirement::Helper);
+ }
+ if !hiddify_installed {
+ missing.push(ConnectRequirement::Hiddify);
+ }
+ if !mihomo_installed {
+ missing.push(ConnectRequirement::Mihomo);
+ }
+ missing
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn lists_helper_then_hiddify_then_mihomo() {
+ assert_eq!(
+ missing_requirements(false, false, false),
+ [
+ ConnectRequirement::Helper,
+ ConnectRequirement::Hiddify,
+ ConnectRequirement::Mihomo
+ ]
+ );
+ assert_eq!(
+ missing_requirements(true, true, true),
+ [] as [ConnectRequirement; 0]
+ );
+ }
+
+ #[test]
+ fn treats_only_unavailable_or_error_helpers_as_missing() {
+ assert!(!helper_is_ready(ComponentPhase::Unavailable));
+ assert!(!helper_is_ready(ComponentPhase::Error));
+ assert!(helper_is_ready(ComponentPhase::Running));
+ assert!(helper_is_ready(ComponentPhase::Stopped));
+ assert!(helper_is_ready(ComponentPhase::Degraded));
+ }
+}
diff --git a/src-tauri/src/helper_install.rs b/src-tauri/src/helper_install.rs
index fa1d012..32b599a 100644
--- a/src-tauri/src/helper_install.rs
+++ b/src-tauri/src/helper_install.rs
@@ -6,7 +6,7 @@ use std::{
path::{Path, PathBuf},
time::Duration,
};
-use tauri::{AppHandle, Manager};
+use tauri::{AppHandle, Manager, Runtime};
use tokio::process::Command;
#[cfg(target_os = "windows")]
use tracing::warn;
@@ -49,7 +49,7 @@ pub struct InstallHelperResult {
///
/// Returns an error when bundled files are missing, elevation fails, or the
/// helper does not become reachable.
-pub async fn install_helper(app: &AppHandle) -> Result {
+pub async fn install_helper(app: &AppHandle) -> Result {
let services = services(app)?;
let resource_root = app
.path()
@@ -106,7 +106,7 @@ pub async fn install_helper(app: &AppHandle) -> Result Result<(), String> {
+async fn wait_for_helper(app: &AppHandle) -> Result<(), String> {
let services = services(app)?;
let attempts = if cfg!(target_os = "windows") { 80 } else { 50 };
for _ in 0..attempts {
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 04f64dc..696cb8d 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,13 +1,20 @@
+mod connect_prep;
mod deps;
mod diagnostics;
mod helper_install;
mod hiddify_reset;
mod network;
+mod traffic;
+mod tray;
mod version;
+mod window_state;
use chrono::Utc;
use iran_split_config::{AppConfig, ConfigStore, ValidationIssue};
-use iran_split_core::{Engine, OperationAccepted, PlatformBackend, StackPhase, StackSnapshot};
+use iran_split_core::{
+ Engine, LifecycleBusy, OperationAccepted, PlatformBackend, StackPhase, StackSnapshot,
+};
+use iran_split_mihomo::ControllerClient;
use iran_split_rules::{
bundled_snapshot_is_complete, ensure_bundled_snapshot, CloudRuleStore, CloudRulesStatus,
DirectRulesDocument, DohResolver, Outbound, RuleManager, RuleSet,
@@ -16,13 +23,16 @@ use serde::Serialize;
use std::{
fs,
path::{Path, PathBuf},
- sync::Arc,
+ sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc,
+ },
time::Duration,
};
use tauri::{
- menu::{Menu, MenuEvent, MenuItem},
+ menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
- AppHandle, Emitter, Manager, Runtime, Window, WindowEvent,
+ AppHandle, Emitter, LogicalSize, Manager, Runtime, Size, Window, WindowEvent,
};
use tauri_plugin_updater::UpdaterExt;
use tracing::{error, info, warn};
@@ -42,6 +52,58 @@ struct AppServices {
cloud_rules: CloudRuleStore,
network: network::NetworkMonitor,
paths: AppPaths,
+ updates: Arc,
+ traffic_lock: tokio::sync::Mutex<()>,
+}
+
+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)]
@@ -363,10 +425,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)]
@@ -505,6 +574,76 @@ async fn get_network_status(app: AppHandle) -> Result Result {
+ diagnostics::trace_action(
+ "traffic",
+ "tauri_command",
+ "get_traffic_totals",
+ async move {
+ let services = services(&app)?;
+ let _guard = services.traffic_lock.lock().await;
+ let path = services.paths.data.join("traffic-totals.json");
+ let mut store = traffic::load(&path);
+ let connected = matches!(
+ services.engine.snapshot().phase,
+ StackPhase::Running | StackPhase::Degraded
+ );
+ let (session_sent, session_received) = if connected {
+ match session_connection_totals(services).await {
+ Ok(totals) => totals,
+ Err(cause) => {
+ warn!(
+ event = "traffic.session_probe_failed",
+ section = "traffic",
+ initiator = "get_traffic_totals",
+ cause = %cause,
+ trace_route = "tauri_command->mihomo_controller",
+ "session traffic totals were unavailable; using last known session"
+ );
+ (store.last_session_sent, store.last_session_received)
+ }
+ }
+ } else {
+ (0, 0)
+ };
+ let totals = traffic::accumulate(&mut store, session_sent, session_received, connected);
+ if let Err(cause) = traffic::save(&path, &store) {
+ warn!(
+ event = "traffic.persist_failed",
+ section = "traffic",
+ initiator = "get_traffic_totals",
+ cause = %cause,
+ trace_route = "tauri_command->traffic_totals_file",
+ "lifetime traffic totals could not be written"
+ );
+ }
+ Ok(totals)
+ },
+ )
+ .await
+}
+
+async fn session_connection_totals(services: &AppServices) -> Result<(u64, u64), String> {
+ let config = services
+ .config_store
+ .load()
+ .or_else(|_| services.config_store.load_or_create())
+ .map_err(|error| error.to_string())?;
+ let client = ControllerClient::new(
+ &config.mihomo.controller_host,
+ config.mihomo.controller_port,
+ &config.mihomo.controller_secret,
+ )
+ .map_err(|error| error.to_string())?;
+ tokio::time::timeout(TRAFFIC_PROBE_TIMEOUT, client.connection_totals())
+ .await
+ .map_err(|_| "traffic probe timed out".to_owned())?
+ .map_err(|error| error.to_string())
+}
+
#[expect(
clippy::needless_pass_by_value,
reason = "Tauri injects AppHandle command arguments by value"
@@ -519,15 +658,96 @@ fn get_stack_snapshot(app: AppHandle) -> Result {
#[tauri::command]
async fn start_stack(app: AppHandle) -> Result {
diagnostics::trace_action("stack", "tauri_command", "start_stack", async move {
- services(&app)?
- .engine
- .start_stack()
- .await
- .map_err(|error| error.to_string())
+ start_stack_inner(&app).await
})
.await
}
+async fn start_stack_inner(app: &AppHandle) -> Result {
+ let engine = &services(app)?.engine;
+ if engine.snapshot().phase == StackPhase::Running {
+ return Ok(OperationAccepted {
+ operation_id: uuid::Uuid::new_v4(),
+ already_complete: true,
+ });
+ }
+ engine
+ .reserve_lifecycle(LifecycleBusy::Connecting)
+ .await
+ .map_err(|error| error.to_string())?;
+ if let Err(error) = prepare_stack_start(app).await {
+ engine.release_lifecycle(LifecycleBusy::Connecting).await;
+ return Err(error);
+ }
+ engine
+ .start_stack()
+ .await
+ .map_err(|error| error.to_string())
+}
+
+async fn prepare_stack_start(app: &AppHandle) -> Result<(), String> {
+ let services = services(app)?;
+ let helper_ready = connect_prep::helper_is_ready(services.engine.snapshot().helper.phase);
+ let statuses = deps::dependency_status(&services.paths.data);
+ let hiddify = statuses
+ .iter()
+ .any(|item| item.id == "hiddify" && item.installed);
+ let mihomo = statuses
+ .iter()
+ .any(|item| item.id == "mihomo" && item.installed);
+ for requirement in connect_prep::missing_requirements(helper_ready, hiddify, mihomo) {
+ info!(
+ event = "connect.install_required",
+ section = "stack",
+ initiator = "prepare_stack_start",
+ cause = "missing_dependency",
+ trace_route = "start_stack->prepare_stack_start",
+ requirement = requirement.as_str(),
+ "installing a required service before connect"
+ );
+ match requirement {
+ connect_prep::ConnectRequirement::Helper => {
+ helper_install::install_helper(app).await?;
+ services.engine.refresh_health().await;
+ if !connect_prep::helper_is_ready(services.engine.snapshot().helper.phase) {
+ return Err("privileged helper is still unavailable after installation".into());
+ }
+ }
+ connect_prep::ConnectRequirement::Hiddify => {
+ install_required_dependency(services, deps::DependencyId::Hiddify).await?;
+ }
+ connect_prep::ConnectRequirement::Mihomo => {
+ install_required_dependency(services, deps::DependencyId::Mihomo).await?;
+ }
+ }
+ }
+ services.engine.refresh_health().await;
+ Ok(())
+}
+
+async fn install_required_dependency(
+ services: &AppServices,
+ id: deps::DependencyId,
+) -> Result<(), String> {
+ let result = deps::install_dependency(id, &services.paths.data, &services.paths.dependencies)
+ .await
+ .map_err(|error| error.to_string())?;
+ if !result.installed {
+ return Err(format!("{} installation did not complete", id.as_str()));
+ }
+ let statuses = deps::dependency_status(&services.paths.data);
+ if !statuses
+ .iter()
+ .any(|item| item.id == id.as_str() && item.installed)
+ {
+ return Err(format!(
+ "{} is still missing after installation",
+ id.as_str()
+ ));
+ }
+ Ok(())
+}
+
#[tauri::command]
async fn stop_stack(app: AppHandle) -> Result {
diagnostics::trace_action("stack", "tauri_command", "stop_stack", async move {
@@ -591,7 +811,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
}
@@ -1140,32 +1362,136 @@ 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,
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 {
@@ -1178,6 +1504,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 {
@@ -1208,6 +1537,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;
}
}
@@ -1226,7 +1558,8 @@ 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
+ let _guard = services(&app)?.updates.begin()?;
+ collect_update_status(&app, "tauri_command").await
})
.await
}
@@ -1239,7 +1572,15 @@ 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 {
+ 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!(
event = "update.background_found",
@@ -1285,33 +1626,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(
@@ -1350,6 +1692,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 +1729,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 +1777,8 @@ 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
+ let _guard = services(&app)?.updates.begin()?;
+ perform_complete_update_install(&app, Uuid::new_v4()).await
})
.await
}
@@ -1426,6 +1801,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",
@@ -1528,6 +1907,8 @@ fn create_services(app: &AppHandle) -> Result {
cloud_rules,
network,
paths,
+ updates: Arc::new(UpdateCoordinator::new()),
+ traffic_lock: tokio::sync::Mutex::new(()),
})
}
@@ -1573,11 +1954,7 @@ fn connect_from_tray(app: &AppHandle) {
let app = app.clone();
tauri::async_runtime::spawn(async move {
let result = diagnostics::trace_action("stack", "tray_menu", "start_stack", async move {
- services(&app)?
- .engine
- .start_stack()
- .await
- .map_err(|error| error.to_string())
+ start_stack_inner(&app).await
})
.await;
if let Err(cause) = result {
@@ -1665,53 +2042,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),
@@ -1727,28 +2059,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",
@@ -1761,38 +2071,80 @@ 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,
+ busy: Option,
+) -> tauri::Result> {
+ let labels = tray::labels_for(phase);
+ let enabled = tray::actions_enabled(busy);
+ let connection = MenuItem::with_id(
+ app,
+ labels.connection_id,
+ labels.connection_label,
+ enabled,
+ None::<&str>,
+ )?;
+ let pause = MenuItem::with_id(
app,
- "disconnect_quit",
- "Disconnect & Quit",
- true,
+ labels.pause_id,
+ labels.pause_label,
+ enabled,
None::<&str>,
)?;
- let menu = Menu::with_items(
+ 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, snapshot: &StackSnapshot) {
+ let Ok(menu) = build_tray_menu(app, snapshot.phase, snapshot.busy) 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 snapshot = app
+ .try_state::()
+ .map_or_else(StackSnapshot::default, |services| {
+ services.engine.snapshot()
+ });
+ let menu = build_tray_menu(app.handle(), snapshot.phase, snapshot.busy)?;
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)
@@ -1857,11 +2209,13 @@ fn setup_application(app: &mut tauri::App) -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box(window: &tauri::WebviewWindow, data: &Path) {
+ let saved = window_state::load(&data.join("window-size.json"));
+ let (work_width, work_height) = monitor_work_area_logical(window)
+ .unwrap_or((window_state::DEFAULT_WIDTH, window_state::DEFAULT_HEIGHT));
+ let size = window_state::clamp_logical(saved.width, saved.height, work_width, work_height);
+ if let Err(cause) = window.set_min_size(Some(Size::Logical(LogicalSize::new(
+ window_state::MIN_WIDTH,
+ window_state::MIN_HEIGHT,
+ )))) {
+ warn!(
+ event = "window.min_size_failed",
+ section = "window",
+ initiator = "restore_main_window_size",
+ cause = %cause,
+ trace_route = "tauri_setup->window.set_min_size",
+ "minimum window size could not be applied"
+ );
+ }
+ if let Err(cause) = window.set_size(Size::Logical(LogicalSize::new(size.width, size.height))) {
+ warn!(
+ event = "window.size_restore_failed",
+ section = "window",
+ initiator = "restore_main_window_size",
+ cause = %cause,
+ trace_route = "tauri_setup->window.set_size",
+ "saved window size could not be applied"
+ );
+ } else {
+ info!(
+ event = "window.size_restored",
+ section = "window",
+ initiator = "restore_main_window_size",
+ cause = "persisted_size",
+ trace_route = "tauri_setup->window.set_size",
+ "main window size restored within the current work area"
+ );
+ }
+}
+
+fn monitor_work_area_logical(window: &tauri::WebviewWindow) -> Option<(f64, f64)> {
+ let monitor = window.current_monitor().ok().flatten()?;
+ let scale = monitor.scale_factor();
+ if scale <= 0.0 {
+ return None;
+ }
+ let work = monitor.work_area();
+ Some((
+ f64::from(work.size.width) / scale,
+ f64::from(work.size.height) / scale,
+ ))
+}
+
+fn persist_main_window_size(window: &Window) {
+ let Ok(services) = services(window.app_handle()) else {
+ return;
+ };
+ let Ok(physical) = window.inner_size() else {
+ return;
+ };
+ let scale = window.scale_factor().unwrap_or(1.0);
+ if scale <= 0.0 {
+ return;
+ }
+ let logical = physical.to_logical::(scale);
+ let (work_width, work_height) = window
+ .current_monitor()
+ .ok()
+ .flatten()
+ .and_then(|monitor| {
+ let scale = monitor.scale_factor();
+ if scale <= 0.0 {
+ return None;
+ }
+ let work = monitor.work_area();
+ Some((
+ f64::from(work.size.width) / scale,
+ f64::from(work.size.height) / scale,
+ ))
+ })
+ .unwrap_or((window_state::DEFAULT_WIDTH, window_state::DEFAULT_HEIGHT));
+ let size = window_state::clamp_logical(logical.width, logical.height, work_width, work_height);
+ if let Err(cause) = window_state::save(&services.paths.data.join("window-size.json"), size) {
+ warn!(
+ event = "window.size_persist_failed",
+ section = "window",
+ initiator = "persist_main_window_size",
+ cause = %cause,
+ trace_route = "window_control->window_size_file",
+ "window size could not be written"
+ );
+ }
+}
+
fn handle_window_event(window: &Window, event: &WindowEvent) {
+ if let WindowEvent::Resized(_) = event {
+ persist_main_window_size(window);
+ }
if let WindowEvent::CloseRequested { api, .. } = event {
info!(
event = "window.close_requested",
@@ -2003,6 +2457,7 @@ pub fn run() {
bootstrap_app,
get_stack_snapshot,
get_network_status,
+ get_traffic_totals,
start_stack,
stop_stack,
pause_stack,
@@ -2049,12 +2504,22 @@ 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};
+ #[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);
@@ -2107,6 +2572,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/src/traffic.rs b/src-tauri/src/traffic.rs
new file mode 100644
index 0000000..ce62743
--- /dev/null
+++ b/src-tauri/src/traffic.rs
@@ -0,0 +1,140 @@
+use serde::{Deserialize, Serialize};
+use std::{fs, path::Path};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct TrafficTotals {
+ pub sent: u64,
+ pub received: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PersistedTraffic {
+ pub lifetime_sent: u64,
+ pub lifetime_received: u64,
+ pub last_session_sent: u64,
+ pub last_session_received: u64,
+}
+
+/// Folds a Mihomo session into lifetime totals so disconnect does not zero the bar.
+#[must_use]
+pub fn accumulate(
+ store: &mut PersistedTraffic,
+ session_sent: u64,
+ session_received: u64,
+ connected: bool,
+) -> TrafficTotals {
+ if connected {
+ if session_sent < store.last_session_sent || session_received < store.last_session_received
+ {
+ store.lifetime_sent = store.lifetime_sent.saturating_add(store.last_session_sent);
+ store.lifetime_received = store
+ .lifetime_received
+ .saturating_add(store.last_session_received);
+ }
+ store.last_session_sent = session_sent;
+ store.last_session_received = session_received;
+ } else if store.last_session_sent > 0 || store.last_session_received > 0 {
+ store.lifetime_sent = store.lifetime_sent.saturating_add(store.last_session_sent);
+ store.lifetime_received = store
+ .lifetime_received
+ .saturating_add(store.last_session_received);
+ store.last_session_sent = 0;
+ store.last_session_received = 0;
+ }
+ TrafficTotals {
+ sent: store.lifetime_sent.saturating_add(store.last_session_sent),
+ received: store
+ .lifetime_received
+ .saturating_add(store.last_session_received),
+ }
+}
+
+pub fn load(path: &Path) -> PersistedTraffic {
+ fs::read(path)
+ .ok()
+ .and_then(|bytes| serde_json::from_slice(&bytes).ok())
+ .unwrap_or_default()
+}
+
+/// Writes persisted totals. Failures are logged by the caller.
+///
+/// # Errors
+///
+/// Returns an I/O or encode error when the file cannot be replaced.
+pub fn save(path: &Path, store: &PersistedTraffic) -> Result<(), String> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).map_err(|error| error.to_string())?;
+ }
+ fs::write(
+ path,
+ serde_json::to_vec_pretty(store).map_err(|error| error.to_string())?,
+ )
+ .map_err(|error| error.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn disconnect_keeps_the_displayed_total() {
+ let mut store = PersistedTraffic::default();
+ let connected = accumulate(&mut store, 1_000, 2_000, true);
+ assert_eq!(
+ connected,
+ TrafficTotals {
+ sent: 1_000,
+ received: 2_000
+ }
+ );
+ let disconnected = accumulate(&mut store, 0, 0, false);
+ assert_eq!(disconnected, connected);
+ let reconnected = accumulate(&mut store, 50, 75, true);
+ assert_eq!(
+ reconnected,
+ TrafficTotals {
+ sent: 1_050,
+ received: 2_075
+ }
+ );
+ }
+
+ #[test]
+ fn a_mihomo_restart_folds_the_previous_session() {
+ let mut store = PersistedTraffic::default();
+ let first = accumulate(&mut store, 500, 500, true);
+ assert_eq!(
+ first,
+ TrafficTotals {
+ sent: 500,
+ received: 500
+ }
+ );
+ let after_restart = accumulate(&mut store, 10, 10, true);
+ assert_eq!(
+ after_restart,
+ TrafficTotals {
+ sent: 510,
+ received: 510
+ }
+ );
+ }
+
+ #[test]
+ fn save_replaces_the_totals_file_atomically_enough_to_reload() {
+ let directory = tempfile::tempdir().expect("tempdir");
+ let path = directory.path().join("traffic-totals.json");
+ let store = PersistedTraffic {
+ lifetime_sent: 9_000,
+ lifetime_received: 8_000,
+ last_session_sent: 100,
+ last_session_received: 200,
+ };
+ save(&path, &store).expect("save");
+ assert_eq!(load(&path), store);
+ assert_eq!(
+ load(directory.path().join("missing.json").as_path()),
+ PersistedTraffic::default()
+ );
+ }
+}
diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs
new file mode 100644
index 0000000..8365b68
--- /dev/null
+++ b/src-tauri/src/tray.rs
@@ -0,0 +1,92 @@
+use iran_split_core::{LifecycleBusy, StackPhase};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct TrayLabels {
+ pub connection_id: &'static str,
+ pub connection_label: &'static str,
+ pub pause_id: &'static str,
+ pub pause_label: &'static str,
+}
+
+/// One item from each pair: Connect/Disconnect and Pause/Resume.
+#[must_use]
+pub fn labels_for(phase: StackPhase) -> 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" },
+ }
+}
+
+#[must_use]
+pub const fn actions_enabled(busy: Option) -> bool {
+ busy.is_none()
+}
+
+#[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"));
+ }
+ }
+
+ #[test]
+ fn tray_actions_disable_while_a_lifecycle_lock_is_held() {
+ assert!(actions_enabled(None));
+ assert!(!actions_enabled(Some(LifecycleBusy::Connecting)));
+ assert!(!actions_enabled(Some(LifecycleBusy::Disconnecting)));
+ assert!(!actions_enabled(Some(LifecycleBusy::Pausing)));
+ assert!(!actions_enabled(Some(LifecycleBusy::Resuming)));
+ }
+}
diff --git a/src-tauri/src/window_state.rs b/src-tauri/src/window_state.rs
new file mode 100644
index 0000000..8d51839
--- /dev/null
+++ b/src-tauri/src/window_state.rs
@@ -0,0 +1,107 @@
+use serde::{Deserialize, Serialize};
+use std::{fs, path::Path};
+
+pub const MIN_WIDTH: f64 = 390.0;
+pub const MIN_HEIGHT: f64 = 640.0;
+pub const DEFAULT_WIDTH: f64 = 1120.0;
+pub const DEFAULT_HEIGHT: f64 = 760.0;
+
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct SavedSize {
+ pub width: f64,
+ pub height: f64,
+}
+
+impl Default for SavedSize {
+ fn default() -> Self {
+ Self {
+ width: DEFAULT_WIDTH,
+ height: DEFAULT_HEIGHT,
+ }
+ }
+}
+
+/// Keeps a restored size inside the monitor work area and the supported minimum.
+#[must_use]
+pub fn clamp_logical(width: f64, height: f64, work_width: f64, work_height: f64) -> SavedSize {
+ let max_width = work_width.max(1.0);
+ let max_height = work_height.max(1.0);
+ SavedSize {
+ width: width.max(MIN_WIDTH.min(max_width)).min(max_width),
+ height: height.max(MIN_HEIGHT.min(max_height)).min(max_height),
+ }
+}
+
+#[must_use]
+pub fn load(path: &Path) -> SavedSize {
+ fs::read(path)
+ .ok()
+ .and_then(|bytes| serde_json::from_slice(&bytes).ok())
+ .unwrap_or_default()
+}
+
+/// Writes the last window size. Failures are logged by the caller.
+///
+/// # Errors
+///
+/// Returns an I/O or encode error when the file cannot be replaced.
+pub fn save(path: &Path, size: SavedSize) -> Result<(), String> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).map_err(|error| error.to_string())?;
+ }
+ fs::write(
+ path,
+ serde_json::to_vec_pretty(&size).map_err(|error| error.to_string())?,
+ )
+ .map_err(|error| error.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn clamp_respects_minimum_and_work_area() {
+ assert_eq!(
+ clamp_logical(200.0, 200.0, 1920.0, 1080.0),
+ SavedSize {
+ width: MIN_WIDTH,
+ height: MIN_HEIGHT
+ }
+ );
+ assert_eq!(
+ clamp_logical(3000.0, 2000.0, 1024.0, 768.0),
+ SavedSize {
+ width: 1024.0,
+ height: 768.0
+ }
+ );
+ }
+
+ #[test]
+ fn clamp_shrinks_to_a_smaller_work_area() {
+ assert_eq!(
+ clamp_logical(800.0, 900.0, 360.0, 600.0),
+ SavedSize {
+ width: 360.0,
+ height: 600.0
+ }
+ );
+ }
+
+ #[test]
+ fn save_round_trips_the_last_size() {
+ let directory = tempfile::tempdir().expect("tempdir");
+ let path = directory.path().join("window-size.json");
+ let size = SavedSize {
+ width: 900.0,
+ height: 700.0,
+ };
+ save(&path, size).expect("save");
+ assert_eq!(load(&path), size);
+ assert_eq!(
+ load(&directory.path().join("missing.json")),
+ SavedSize::default()
+ );
+ }
+}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 08a2c36..2646726 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.3.2",
"identifier": "app.biflow.desktop",
"build": {
"beforeDevCommand": "pnpm bundle:check && pnpm --dir apps/desktop dev",
@@ -19,12 +19,10 @@
"title": "BiFlow",
"width": 1120,
"height": 760,
- "minWidth": 1120,
- "minHeight": 760,
- "maxWidth": 1120,
- "maxHeight": 760,
+ "minWidth": 390,
+ "minHeight": 640,
"center": true,
- "resizable": false
+ "resizable": true
}
]
},
diff --git a/version b/version
index cb2b00e..4772543 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-3.0.1
+3.3.2