diff --git a/AGENTS.md b/AGENTS.md index fe4e9c1..746e2f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,12 @@ If a required command fails or emits a warning from project code, fix it in the ## Lessons +- A failed macOS disk-image open must not resume a user-paused stack. Only + package kinds that pause the stack for installation may restore it after + failure; pass the install kind into the recovery decision (ADR 0129). +- Scope About update assertions to its region: the network footer also has + a `status` role, so a page-wide status locator matches two elements. + - macOS ships Bash 3.2, where expanding an empty array under `set -u` fails even after `array=()`. Guard optional build arguments with `${array[@]+"${array[@]}"}` and test signed/unsigned argument forwarding @@ -752,6 +758,16 @@ SHA-256")`); launchd `KeepAlive` then restarts it in a crash loop and the default gateway, and delete `netstat` routes that still name the TUN. Kill a scoped Mihomo orphan on stop when the child handle is already empty (ADR 0121). +- Calling `sizeToFit` and `setFrame` on the macOS status button every second + enters `NSStatusItemScene updateSettings`, which `performAsyncAndWait`s. + On macOS 26 that wait can sit on the main thread until force quit (73 + minutes on 23 October 2026) while `MenuItem::with_id` from a worker blocks + forever on the same thread. Change only the attributed title when the + column width is unchanged, and rebuild the tray menu on the main thread + only when phase or busy changes (ADR 0122). +- A `--skip-jenkins` DMG retry that drops `--app-drop-link` ships a disk + image with no Applications alias. Keep the same positions and background + as `tauri.macos.conf.json` on that retry (ADR 0128). - macOS `NET_RT_IFLIST2` `if_data64.ifi_ibytes` does not match `netstat -ib` on current Darwin. Menu bar speeds must read `IFMIB_IFDATA` / `IFDATA_GENERAL`. Wi-Fi reports `IFT_ETHER`, so classify with @@ -772,3 +788,20 @@ SHA-256")`); launchd `KeepAlive` then restarts it in a crash loop and the `BiFlow_*_aarch64.dmg`. Sign that disk image in the release job and publish it as the `macos-aarch64` entry. The platform key must stay identical to `signed_target` for `InstallKind::Dmg` on `aarch64` (ADR 0126). +- A macOS-only update outcome must be `#[cfg(target_os = "macos")]` on the + enum variant and on every match arm. Linux and Windows Clippy compile + that variant as dead code when only the macOS installer constructs it + (ADR 0129). +- `cargo deny check` fails a pull request when `rustls-webpki`, `tokio`, + `webpki-roots`, or `webpki-root-certs` fall inside a new RustSec range. + Update them with `cargo update -p`. `webpki-roots` has two majors, so + name `webpki-roots@1` (RUSTSEC-2026-0381, 0382, 0383, 0386). +- macOS in-app update must not replace `/Applications/BiFlow.app` or quit. + Closing the window only hides the menu-bar process, so an automatic swap + and relaunch was easy to miss and left the operator on the old version + when the helper never ran. Download the signed disk image, open it with + `/usr/bin/open`, and leave the tunnel up. The new version starts only + after the operator drags BiFlow onto Applications, quits the menu-bar + process, and opens that Applications copy (ADR 0129). Linux `.deb` still + waits for a manual restart. AppImage and NSIS still use their replace + helpers. diff --git a/Cargo.lock b/Cargo.lock index 2c6a250..c5b0f4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,7 +77,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -88,7 +88,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -953,7 +953,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1145,7 +1145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1820,7 +1820,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", + "webpki-roots 1.0.11", ] [[package]] @@ -2046,7 +2046,7 @@ dependencies = [ [[package]] name = "iran-split-cli" -version = "6.2.75" +version = "6.2.80" dependencies = [ "async-trait", "chrono", @@ -2064,7 +2064,7 @@ dependencies = [ [[package]] name = "iran-split-clients" -version = "6.2.75" +version = "6.2.80" dependencies = [ "async-trait", "ipnet", @@ -2080,7 +2080,7 @@ dependencies = [ [[package]] name = "iran-split-config" -version = "6.2.75" +version = "6.2.80" dependencies = [ "hex", "rand 0.9.5", @@ -2093,7 +2093,7 @@ dependencies = [ [[package]] name = "iran-split-core" -version = "6.2.75" +version = "6.2.80" dependencies = [ "async-trait", "chrono", @@ -2110,7 +2110,7 @@ dependencies = [ [[package]] name = "iran-split-desktop" -version = "6.2.75" +version = "6.2.80" dependencies = [ "chrono", "dirs 6.0.0", @@ -2152,7 +2152,7 @@ dependencies = [ [[package]] name = "iran-split-helper" -version = "6.2.75" +version = "6.2.80" dependencies = [ "clap", "hex", @@ -2177,7 +2177,7 @@ dependencies = [ [[package]] name = "iran-split-helper-winacl" -version = "6.2.75" +version = "6.2.80" dependencies = [ "tokio", "windows", @@ -2185,7 +2185,7 @@ dependencies = [ [[package]] name = "iran-split-ipc" -version = "6.2.75" +version = "6.2.80" dependencies = [ "bytes", "chrono", @@ -2198,7 +2198,7 @@ dependencies = [ [[package]] name = "iran-split-mihomo" -version = "6.2.75" +version = "6.2.80" dependencies = [ "chrono", "futures-util", @@ -2221,7 +2221,7 @@ dependencies = [ [[package]] name = "iran-split-netstats" -version = "6.2.75" +version = "6.2.80" dependencies = [ "libc", "tempfile", @@ -2230,7 +2230,7 @@ dependencies = [ [[package]] name = "iran-split-platform-linux" -version = "6.2.75" +version = "6.2.80" dependencies = [ "async-trait", "chrono", @@ -2254,7 +2254,7 @@ dependencies = [ [[package]] name = "iran-split-platform-macos" -version = "6.2.75" +version = "6.2.80" dependencies = [ "async-trait", "chrono", @@ -2278,7 +2278,7 @@ dependencies = [ [[package]] name = "iran-split-platform-win" -version = "6.2.75" +version = "6.2.80" dependencies = [ "async-trait", "chrono", @@ -2301,7 +2301,7 @@ dependencies = [ [[package]] name = "iran-split-rules" -version = "6.2.75" +version = "6.2.80" dependencies = [ "async-trait", "chrono", @@ -2323,7 +2323,7 @@ dependencies = [ [[package]] name = "iran-split-taskbar" -version = "6.2.75" +version = "6.2.80" dependencies = [ "iran-split-config", "objc2", @@ -2733,7 +2733,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2806,7 +2806,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3096,7 +3096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -3508,7 +3508,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3708,7 +3708,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.9", + "webpki-roots 1.0.11", ] [[package]] @@ -3813,7 +3813,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3870,7 +3870,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3881,9 +3881,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -4307,7 +4307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4912,7 +4912,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5044,9 +5044,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -5385,7 +5385,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5450,7 +5450,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5861,9 +5861,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] @@ -5874,14 +5874,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.11", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -5950,7 +5950,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index de75fab..5461950 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ members = [ ] [workspace.package] -version = "6.2.75" +version = "6.2.80" edition = "2021" license = "MIT OR Apache-2.0" rust-version = "1.88" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0b70f3b..e3514c5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@iran-split/desktop", - "version": "6.2.75", + "version": "6.2.80", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src/api/mock.test.ts b/apps/desktop/src/api/mock.test.ts index 907a205..0247c97 100644 --- a/apps/desktop/src/api/mock.test.ts +++ b/apps/desktop/src/api/mock.test.ts @@ -1,10 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MOCK_HIDDIFY_ID } from "../lib/outbound"; import { APP_VERSION } from "../version"; import type { StackSnapshot } from "./models"; import { mockApi, resetMockState } from "./mock"; describe("mock transport", () => { + afterEach(() => vi.restoreAllMocks()); + beforeEach(() => { sessionStorage.removeItem("biflow-mock-force-missing-helper"); resetMockState(); @@ -172,6 +174,7 @@ describe("mock transport", () => { }); it("emits download progress during mock install", async () => { + vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); sessionStorage.setItem("biflow-mock-update-available", "1"); const phases: string[] = []; const unsubscribe = mockApi.subscribeUpdateProgress((progress) => { @@ -183,6 +186,22 @@ describe("mock transport", () => { expect(phases.at(-1)).toBe("restarting"); }); + it("finishes a macOS update as downloaded without starting an installer", async () => { + vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + sessionStorage.setItem("biflow-mock-update-available", "1"); + const before = await mockApi.getSnapshot(); + const phases: string[] = []; + const unsubscribe = mockApi.subscribeUpdateProgress((progress) => { + phases.push(progress.phase); + }); + await mockApi.installUpdate(); + unsubscribe(); + expect(phases.at(-1)).toBe("downloaded"); + expect(phases).not.toContain("installing"); + expect(phases).not.toContain("restarting"); + expect(await mockApi.getSnapshot()).toEqual(before); + }); + it("publishes real start stages on the snapshot", async () => { const stages: Array = []; const componentSnapshots: StackSnapshot[] = []; diff --git a/apps/desktop/src/api/mock.ts b/apps/desktop/src/api/mock.ts index e26b709..96a26b4 100644 --- a/apps/desktop/src/api/mock.ts +++ b/apps/desktop/src/api/mock.ts @@ -637,6 +637,15 @@ async function simulateInstallProgress(version: string) { }); await new Promise((resolve) => setTimeout(resolve, 20)); } + if (/mac/i.test(navigator.platform)) { + emitUpdateProgress({ + phase: "downloaded", + percent: 100, + version, + error: null, + }); + return; + } emitUpdateProgress({ phase: "installing", percent: 100, diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts index d380b8b..632ffaa 100644 --- a/apps/desktop/src/api/models.ts +++ b/apps/desktop/src/api/models.ts @@ -387,6 +387,7 @@ export type UpdatePhase = | "installing" | "restarting" | "installed" + | "downloaded" | "failed"; export interface UpdateProgress { diff --git a/apps/desktop/src/components/About.test.tsx b/apps/desktop/src/components/About.test.tsx index 6929fb1..487489d 100644 --- a/apps/desktop/src/components/About.test.tsx +++ b/apps/desktop/src/components/About.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { About } from "./About"; import { useAppStore } from "../store/app"; +import type { BootstrapResult } from "../api/models"; import i18n from "../i18n/config"; vi.mock("../version", () => ({ @@ -17,6 +18,7 @@ describe("About", () => { beforeEach(() => { useAppStore.setState({ + boot: null, update: { phase: "idle", percent: null, @@ -79,6 +81,41 @@ describe("About", () => { ); }); + it("tells the operator to drag a downloaded macOS disk image", () => { + useAppStore.setState({ + update: { + phase: "downloaded", + percent: 100, + version: "1.3.0", + error: null, + }, + }); + render(); + expect(screen.getByRole("status")).toHaveTextContent( + /drag BiFlow onto Applications/i, + ); + expect(screen.getByRole("status")).toHaveTextContent(/menu bar/i); + }); + + it("offers a download on macOS and an install elsewhere", async () => { + const installUpdate = vi.fn(async () => undefined); + useAppStore.setState({ + boot: { platform: "macos" } as BootstrapResult, + update: { + phase: "available", + percent: null, + version: "1.3.0", + error: null, + }, + installUpdate, + }); + render(); + await userEvent.click( + screen.getByRole("button", { name: /download 1\.3\.0/i }), + ); + expect(installUpdate).toHaveBeenCalledOnce(); + }); + it("offers install when an update is available", async () => { const installUpdate = vi.fn(async () => undefined); useAppStore.setState({ diff --git a/apps/desktop/src/components/About.tsx b/apps/desktop/src/components/About.tsx index 43474d3..1abd81d 100644 --- a/apps/desktop/src/components/About.tsx +++ b/apps/desktop/src/components/About.tsx @@ -9,6 +9,7 @@ const REPOSITORY_URL = "https://github.com/devlifeX/BiFlow"; export function About() { const { t } = useTranslation(); const update = useAppStore((state) => state.update); + const platform = useAppStore((state) => state.boot?.platform); const checkForUpdate = useAppStore((state) => state.checkForUpdate); const installUpdate = useAppStore((state) => state.installUpdate); const retryUpdate = useAppStore((state) => state.retryUpdate); @@ -120,7 +121,9 @@ export function About() { disabled={busy} onClick={() => void installUpdate()} > - {t("updateInstall", { version: update.version ?? "" })} + {platform === "macos" + ? t("updateDownload", { version: update.version ?? "" }) + : t("updateInstall", { version: update.version ?? "" })} ) : null} {showRetry ? ( @@ -171,6 +174,8 @@ function updateMessage( return t("updateRestarting"); case "installed": return t("updateInstalled"); + case "downloaded": + return t("updateDownloaded", { version: update.version ?? "" }); case "failed": return t("updateFailed"); default: diff --git a/apps/desktop/src/i18n/config.ts b/apps/desktop/src/i18n/config.ts index 6e89fe5..0d2b3ce 100644 --- a/apps/desktop/src/i18n/config.ts +++ b/apps/desktop/src/i18n/config.ts @@ -265,12 +265,15 @@ const resources = { updateAvailable: "Version {{version}} is available.", updateFailed: "Could not check for updates.", updateInstall: "Install update {{version}}", + updateDownload: "Download {{version}}", updateRetry: "Retry update", updateDownloading: "Downloading version {{version}}…", updateInstalling: "Installing update…", updateRestarting: "Restarting BiFlow…", updateInstalled: "BiFlow was updated. Quit and open the app again to use the new version.", + updateDownloaded: + "Version {{version}} is downloaded. The disk image is open. Drag BiFlow onto Applications, quit this BiFlow from the menu bar, then open the copy in Applications.", updateDownloadProgress: "Download progress", updateIdle: "Check GitHub Releases when you are ready to update.", cloudRules: "Iran cloud rules", @@ -685,12 +688,15 @@ const resources = { updateAvailable: "نسخه {{version}} در دسترس است.", updateFailed: "بررسی به‌روزرسانی انجام نشد.", updateInstall: "نصب به‌روزرسانی {{version}}", + updateDownload: "دانلود {{version}}", updateRetry: "تلاش دوباره", updateDownloading: "در حال دانلود نسخه {{version}}…", updateInstalling: "در حال نصب به‌روزرسانی…", updateRestarting: "در حال راه‌اندازی مجدد BiFlow…", updateInstalled: "BiFlow به‌روزرسانی شد. برنامه را ببندید و دوباره باز کنید تا نسخه جدید دیده شود.", + updateDownloaded: + "نسخه {{version}} دانلود شد. تصویر دیسک باز است. BiFlow را روی Applications بکشید، این برنامه را از نوار منو ببندید، سپس نسخه داخل Applications را باز کنید.", updateDownloadProgress: "پیشرفت دانلود", updateIdle: "وقتی آماده بودید، به‌روزرسانی GitHub Releases را بررسی کنید.", diff --git a/build.sh b/build.sh index 4c32458..fe2522e 100755 --- a/build.sh +++ b/build.sh @@ -64,6 +64,26 @@ macos_app_name() { command printf 'BiFlow.app\n' } +# Positions match `src-tauri/tauri.macos.conf.json`. `--skip-jenkins` skips +# Finder layout, but `--app-drop-link` still creates the Applications alias. +macos_dmg_drag_args() { + local app + app="$(macos_app_name)" + command printf '%s\n' \ + --volname BiFlow \ + --icon "${app}" \ + 180 \ + 170 \ + --app-drop-link \ + 480 \ + 170 \ + --window-size \ + 660 \ + 400 \ + --hide-extension "${app}" \ + --background "${PROJECT_DIR}/packaging/macos/dmg-background.png" +} + usage() { command cat <<'EOF' BiFlow release builder @@ -879,7 +899,15 @@ build_macos() { "${app_dir}"/rw.*.dmg \ "${dmg_dir}"/rw.*.dmg \ "${dmg_dir}/$(macos_dmg_name)" + [[ -f "${PROJECT_DIR}/packaging/macos/dmg-background.png" ]] || \ + die "dmg background is missing" + local drag_args=() + local drag_arg + while IFS= read -r drag_arg; do + drag_args+=("${drag_arg}") + done < <(macos_dmg_drag_args) ( cd -- "${dmg_dir}" && bash "${dmg_script}" --skip-jenkins \ + ${drag_args[@]+"${drag_args[@]}"} \ "$(macos_dmg_name)" "${app_dir}" ) \ || die "bundle_dmg.sh --skip-jenkins failed" fi diff --git a/crates/iran-split-taskbar/src/lib.rs b/crates/iran-split-taskbar/src/lib.rs index 2189cee..2f23369 100644 --- a/crates/iran-split-taskbar/src/lib.rs +++ b/crates/iran-split-taskbar/src/lib.rs @@ -96,6 +96,56 @@ pub fn format_rate_in(rate: Option, unit: SpeedUnit) -> String { /// is the longest automatic rate, so normal updates do not resize the item. const MENU_BAR_COLUMNS: usize = 11; +/// What the menu bar already shows, if this process has drawn it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MenuBarRecord<'a> { + /// No title has been applied in this process. + Unknown, + /// The status icon is visible and the speed title is clear. + Hidden, + /// The stacked speed title currently on the status item. + Shown(&'a str), +} + +/// How the next menu-bar sample may touch `AppKit`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MenuBarUpdate { + /// The visible title already matches. Do not call `AppKit`. + Unchanged, + /// Replace the attributed title. The item width stays the same. + Title, + /// Show or hide the speeds, or change the line width. + Layout, +} + +/// Digit changes inside the fixed column width must not resize the status item. +/// +/// Resizing enters the status-item scene update, which can block the main +/// thread until the process is force-quit. +#[must_use] +pub fn menu_bar_update(previous: MenuBarRecord<'_>, next: Option<&str>) -> MenuBarUpdate { + match (previous, next) { + (MenuBarRecord::Unknown | MenuBarRecord::Hidden, None) => MenuBarUpdate::Unchanged, + (MenuBarRecord::Shown(previous), Some(next)) if previous == next => { + MenuBarUpdate::Unchanged + } + (MenuBarRecord::Shown(previous), Some(next)) + if menu_bar_line_width(previous) == menu_bar_line_width(next) => + { + MenuBarUpdate::Title + } + _ => MenuBarUpdate::Layout, + } +} + +fn menu_bar_line_width(label: &str) -> usize { + label + .lines() + .map(|line| line.chars().count()) + .max() + .unwrap_or(0) +} + /// Upload above download, each line the same width, arrows aligned. #[must_use] pub fn menu_bar_label(upload: Option, download: Option, unit: SpeedUnit) -> String { @@ -194,6 +244,41 @@ mod tests { assert!(wide[0].chars().count() > MENU_BAR_COLUMNS); } + #[test] + fn digit_changes_do_not_resize_the_menu_bar_item() { + let idle = menu_bar_label(None, Some(0.0), SpeedUnit::AutoBits); + let live = menu_bar_label(Some(14_000.0), Some(450_000.0), SpeedUnit::AutoBits); + assert_eq!( + menu_bar_update(MenuBarRecord::Shown(&idle), Some(&live)), + MenuBarUpdate::Title + ); + assert_eq!( + menu_bar_update(MenuBarRecord::Shown(&live), Some(&live)), + MenuBarUpdate::Unchanged + ); + assert_eq!( + menu_bar_update(MenuBarRecord::Unknown, Some(&live)), + MenuBarUpdate::Layout + ); + assert_eq!( + menu_bar_update(MenuBarRecord::Shown(&live), None), + MenuBarUpdate::Layout + ); + assert_eq!( + menu_bar_update(MenuBarRecord::Hidden, None), + MenuBarUpdate::Unchanged + ); + assert_eq!( + menu_bar_update(MenuBarRecord::Unknown, None), + MenuBarUpdate::Unchanged + ); + let wide = menu_bar_label(Some(2_000_000_000.0), Some(1.0), SpeedUnit::Bits); + assert_eq!( + menu_bar_update(MenuBarRecord::Shown(&live), Some(&wide)), + MenuBarUpdate::Layout + ); + } + #[test] fn upload_is_above_download_with_distinct_values() { assert_eq!( diff --git a/crates/iran-split-taskbar/src/macos.rs b/crates/iran-split-taskbar/src/macos.rs index a6fbb7b..93f37d2 100644 --- a/crates/iran-split-taskbar/src/macos.rs +++ b/crates/iran-split-taskbar/src/macos.rs @@ -1,5 +1,8 @@ //! Two-line menu bar title. A plain status-item title stays one proportional //! line, so the item changes width every time the rate changes. +use std::cell::Cell; +use std::sync::Mutex; + use objc2::runtime::AnyObject; use objc2::{AnyThread, MainThreadMarker, Message}; use objc2_app_kit::{ @@ -8,6 +11,19 @@ use objc2_app_kit::{ }; use objc2_foundation::{NSAttributedString, NSDictionary, NSRect, NSString}; +use crate::{MenuBarRecord, MenuBarUpdate}; + +#[derive(Clone)] +enum SavedLabel { + Hidden, + Shown(String), +} + +static SAVED_LABEL: Mutex> = Mutex::new(None); +thread_local! { + static APPLYING: Cell = const { Cell::new(false) }; +} + /// True when `AppKit` calls are legal on this thread. #[must_use] pub fn menu_bar_is_main_thread() -> bool { @@ -16,44 +32,109 @@ pub fn menu_bar_is_main_thread() -> bool { /// Draws `label` on `BiFlow`'s status item. `None` clears the title. /// -/// Returns whether that status item was found. +/// Returns whether that status item was found, or whether nothing needed to change. #[must_use] pub fn apply_menu_bar_label(label: Option<&str>) -> bool { let Some(marker) = MainThreadMarker::new() else { return false; }; + if APPLYING.with(Cell::get) { + tracing::warn!( + event = "taskbar.menu_bar_reentered", + section = "taskbar", + initiator = "interface_sampler", + cause = "appkit_reentry", + trace_route = "shared_stats->tray_label->menu_bar", + "menu bar update skipped because AppKit reentered it" + ); + return true; + } + let previous = saved_record(); + let record = match previous.as_ref() { + None => MenuBarRecord::Unknown, + Some(SavedLabel::Hidden) => MenuBarRecord::Hidden, + Some(SavedLabel::Shown(text)) => MenuBarRecord::Shown(text), + }; + let update = crate::menu_bar_update(record, label); + if update == MenuBarUpdate::Unchanged { + return true; + } let Some(button) = status_button(marker) else { return false; }; - let tip = NSString::from_str("BiFlow"); - button.setToolTip(Some(&tip)); - if let Some(label) = label { - let font = NSFont::monospacedSystemFontOfSize_weight(9.0, 0.0); - let style = NSMutableParagraphStyle::new(); - style.setMaximumLineHeight(9.0); - style.setLineSpacing(-2.0); - let text = NSString::from_str(label); - let attrs = attributes(&font, &style, &NSColor::labelColor()); - // SAFETY: the dictionary uses the font, paragraph, and foreground - // attribute names, and each value is the matching AppKit object. - let title = unsafe { - NSAttributedString::initWithString_attributes( - NSAttributedString::alloc(), - &text, - Some(&attrs), - ) + let _guard = ApplyGuard::enter(); + // A title-only change must not call `sizeToFit`. If the logo is visible + // again, the status item was recreated and needs one layout. + if update == MenuBarUpdate::Title && button.image().is_none() { + let Some(label) = label else { + return false; }; - button.setFont(Some(&font)); - button.setAttributedTitle(&title); + set_stacked_title(&button, label); + remember(label); + return true; + } + button.setToolTip(Some(&NSString::from_str("BiFlow"))); + if let Some(label) = label { + set_stacked_title(&button, label); show_logo(&button, false); } else { button.setTitle(&NSString::from_str("")); show_logo(&button, true); } fit_click_target(&button); + remember_layout(label); true } +fn saved_record() -> Option { + SAVED_LABEL.lock().expect("menu bar label").clone() +} + +fn remember(label: &str) { + *SAVED_LABEL.lock().expect("menu bar label") = Some(SavedLabel::Shown(label.to_owned())); +} + +fn remember_layout(label: Option<&str>) { + *SAVED_LABEL.lock().expect("menu bar label") = Some(match label { + Some(label) => SavedLabel::Shown(label.to_owned()), + None => SavedLabel::Hidden, + }); +} + +struct ApplyGuard; + +impl ApplyGuard { + fn enter() -> Self { + APPLYING.with(|applying| applying.set(true)); + Self + } +} + +impl Drop for ApplyGuard { + fn drop(&mut self) { + APPLYING.with(|applying| applying.set(false)); + } +} + +fn set_stacked_title(button: &NSButton, label: &str) { + let font = NSFont::monospacedSystemFontOfSize_weight(9.0, 0.0); + let style = NSMutableParagraphStyle::new(); + style.setMaximumLineHeight(9.0); + style.setLineSpacing(-2.0); + let text = NSString::from_str(label); + let attrs = attributes(&font, &style, &NSColor::labelColor()); + // SAFETY: the dictionary uses the font, paragraph, and foreground + // attribute names, and each value is the matching AppKit object. + let title = unsafe { + NSAttributedString::initWithString_attributes( + NSAttributedString::alloc(), + &text, + Some(&attrs), + ) + }; + button.setAttributedTitle(&title); +} + fn attributes( font: &NSFont, style: &NSMutableParagraphStyle, diff --git a/docs/adr/0122-macos-menu-bar-speed.md b/docs/adr/0122-macos-menu-bar-speed.md index 7043c56..e80ac7d 100644 --- a/docs/adr/0122-macos-menu-bar-speed.md +++ b/docs/adr/0122-macos-menu-bar-speed.md @@ -42,3 +42,16 @@ BiFlow's status button was found and the stacked title was applied. Coverage includes virtual-name and media classification, default-route selection, a host read without elevation, equal-width menu bar lines, macOS settings unit tests, and the English/Persian speed-save flow. + +Amendment (6.2.77): the one-second sampler used to call `sizeToFit`, move the +click overlay, and clear the status image on every tick, including while the +speeds were hidden. On macOS 26 that enters `NSStatusItemScene updateSettings`, +which waits with `performAsyncAndWait`. A hang on 23 October 2026 left the +main thread there for 73 minutes (`blocked by queue in a transient state`). +A tray-menu rebuild on a worker called `MenuItem::with_id`, which waits for +that same thread, so the menu never returned and the window had to be force +quit. Digit changes that keep the fixed column width now replace only the +attributed title. `sizeToFit`, the overlay frame, and the icon change only +when speeds are shown or hidden, when a line gets wider, or when the logo is +visible again. Identical text does not touch `AppKit`. Tray menu rebuilds run +on the main thread and only when the phase or busy flag changes. diff --git a/docs/adr/0127-macos-update-relaunch.md b/docs/adr/0127-macos-update-relaunch.md new file mode 100644 index 0000000..ce4b8b3 --- /dev/null +++ b/docs/adr/0127-macos-update-relaunch.md @@ -0,0 +1,22 @@ +# ADR 0127: Relaunch the replaced macOS app after the old process exits + +Status: Superseded by [ADR 0129](./0129-macos-update-downloads-only.md) (6.2.78) + +A finished macOS update told the operator to quit and open BiFlow again. The +download had completed and `/Applications/BiFlow.app` may already have been +replaced, but the version on screen did not change. + +Closing the window does not quit BiFlow. The close handler hides the window +and leaves the process in the menu bar. Opening the app again is delivered to +that same process by the single-instance plugin, so the operator keeps running +the binary that started the update. Replacing `/Applications/BiFlow.app` while +that process is still mapped is also unreliable, and a copy launched from the +disk image is not the bundle the operator reopens. + +The disk image is staged with `ditto` while BiFlow is still open. The +administrator prompt starts a detached helper and returns immediately. BiFlow +then exits the same way AppImage and NSIS updates do. The helper waits until +that process id is gone, checks the staged bundle's +`CFBundleShortVersionString`, swaps `/Applications/BiFlow.app`, and opens that +bundle as the desktop user. It does not launch the updated app as root. A +version mismatch leaves the installed app in place and opens it again. diff --git a/docs/adr/0128-macos-drag-install.md b/docs/adr/0128-macos-drag-install.md new file mode 100644 index 0000000..93e005f --- /dev/null +++ b/docs/adr/0128-macos-drag-install.md @@ -0,0 +1,21 @@ +# ADR 0128: macOS disk image is a drag-to-Applications install + +Status: Accepted (6.2.77) + +A macOS install is a disk image the operator opens and drags onto +Applications. Tauri's bundler creates that alias only when it passes +`--app-drop-link`. The positions live in `src-tauri/tauri.macos.conf.json`: +`BiFlow.app` at (180, 170), Applications at (480, 170), and a 660 by 400 +window. `packaging/macos/dmg-background.png` draws the arrow between them. + +The non-GUI retry used to run `bundle_dmg.sh --skip-jenkins` with only the +file name and the app directory. Finder's layout script is what `--skip-jenkins` +skips, but omitting `--app-drop-link` also omitted the Applications alias, so +a failed AppleScript produced a disk image with nothing to drag onto. +`--skip-jenkins` still skips icon positions. The retry now passes the same +volume name, icon position, Applications link, window size, and background as +the normal bundle. The alias is on the image either way. + +The in-app updater opens this same disk image and leaves the running app in +place (ADR 0129). This decision is the disk image a person opens from the +release or from About. diff --git a/docs/adr/0129-macos-update-downloads-only.md b/docs/adr/0129-macos-update-downloads-only.md new file mode 100644 index 0000000..0659ac0 --- /dev/null +++ b/docs/adr/0129-macos-update-downloads-only.md @@ -0,0 +1,22 @@ +# ADR 0129: macOS update downloads a disk image and does not restart + +Status: Accepted (6.2.78) + +Supersedes the install half of [ADR 0127](./0127-macos-update-relaunch.md). + +About → Install on macOS verifies the signed disk image, then opens it with +`/usr/bin/open`. Finder shows the drag-to-Applications window from +[ADR 0128](./0128-macos-drag-install.md). BiFlow does not mount the image +itself, does not replace `/Applications/BiFlow.app`, does not ask for an +administrator password, does not pause the tunnel, and does not quit. + +The running menu-bar process is still the old version. Closing the window +only hides it, and the single-instance plugin delivers a later launch to that +same process. The new version runs after the operator quits BiFlow from the +menu bar and opens the Applications copy. Linux `.deb` still waits for a +manual restart. AppImage and NSIS still hand off to their replace helpers. + +Amendment (6.2.80): failure to open the downloaded disk image also preserves +the stack state. The recovery path receives the install kind and resumes a +paused stack only for an in-place installer. A user-paused macOS connection +must remain paused when Finder cannot open the image. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2583490..f667a23 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -130,3 +130,6 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th | [0124](./0124-pr-validation-and-safe-archives.md) | Cross-platform PR validation and safe archive dependencies | Accepted | | [0125](./0125-native-release-bootstrap.md) | Native release bootstrap on macOS and Windows | Accepted | | [0126](./0126-macos-updater-platform.md) | Publish the Apple Silicon disk image as an updater platform | Accepted | +| [0127](./0127-macos-update-relaunch.md) | Relaunch the replaced macOS app after the old process exits | Superseded | +| [0128](./0128-macos-drag-install.md) | macOS disk image is a drag-to-Applications install | Accepted | +| [0129](./0129-macos-update-downloads-only.md) | macOS update downloads a disk image and does not restart | Accepted | diff --git a/docs/screenshots/settings-speed.png b/docs/screenshots/settings-speed.png index 9d28f28..1a9e3b5 100644 Binary files a/docs/screenshots/settings-speed.png and b/docs/screenshots/settings-speed.png differ diff --git a/e2e/primary-flows.spec.ts b/e2e/primary-flows.spec.ts index 1435730..d03c8b6 100644 --- a/e2e/primary-flows.spec.ts +++ b/e2e/primary-flows.spec.ts @@ -836,13 +836,49 @@ test.describe("primary BiFlow flows", () => { ); await page.getByRole("button", { name: "Check for updates" }).click(); await expect(page.getByText(/Version 9\.9\.9 is available/i)).toBeVisible(); - await page.getByRole("button", { name: /Install update 9\.9\.9/i }).click(); + await page + .getByRole("button", { name: /(?:Install update|Download) 9\.9\.9/i }) + .click(); await expect(page.getByRole("progressbar")).toBeVisible(); await expect( page.getByText("an update is already in progress"), ).toHaveCount(0); }); + test("downloads the macOS update and preserves a paused connection", async ({ + page, + }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "platform", { get: () => "MacIntel" }); + }); + await openFresh(page); + await connectButton(page).click(); + const pause = page.locator("[data-connection-action='pause']"); + await expect(pause).toBeEnabled(); + await pause.click(); + await expect( + page.getByRole("heading", { name: "Paused", exact: true }), + ).toBeVisible(); + await goTo(page, "Settings", "About"); + await page.evaluate(() => + sessionStorage.setItem("biflow-mock-update-available", "1"), + ); + await page.getByRole("button", { name: "Check for updates" }).click(); + await page.getByRole("button", { name: "Download 9.9.9" }).click(); + await expect( + page + .getByRole("region", { name: "About", exact: true }) + .getByRole("status"), + ).toContainText("Drag BiFlow onto Applications"); + await expect( + page.getByRole("button", { name: "Check for updates" }), + ).toBeEnabled(); + await goTo(page, "Home"); + await expect( + page.getByRole("heading", { name: "Paused", exact: true }), + ).toBeVisible(); + }); + test("keeps a subdomain pin exact while connected", async ({ page }) => { await openFresh(page); await goTo(page, "Routing", "Sites"); diff --git a/package.json b/package.json index ef0648f..233659c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iran-split-desktop-workspace", - "version": "6.2.75", + "version": "6.2.80", "private": true, "packageManager": "pnpm@9.0.1", "engines": { diff --git a/packaging/macos/dmg-background.png b/packaging/macos/dmg-background.png new file mode 100644 index 0000000..04e9ea9 Binary files /dev/null and b/packaging/macos/dmg-background.png differ diff --git a/scripts/build-plan.test.mjs b/scripts/build-plan.test.mjs index 5edfde1..541f2cf 100644 --- a/scripts/build-plan.test.mjs +++ b/scripts/build-plan.test.mjs @@ -201,6 +201,38 @@ describe("release artifact names", () => { assert.match(source, /^#!\[cfg\(target_os = "linux"\)\]/); }); + it("keeps the Applications alias when the macOS disk image skips Finder layout", () => { + const source = readFileSync(join(root, "build.sh"), "utf8"); + const dragArgs = source.match(/macos_dmg_drag_args\(\) \{([\s\S]*?)\n\}/); + assert.ok(dragArgs); + assert.match(dragArgs[1], /--app-drop-link/); + assert.match(dragArgs[1], /480/); + assert.match(dragArgs[1], /170/); + assert.match(dragArgs[1], /dmg-background\.png/); + const fallback = source.match( + /bash "\$\{dmg_script\}" --skip-jenkins([\s\S]*?)macos_dmg_name/, + ); + assert.ok(fallback); + assert.match(fallback[1], /drag_args/); + const macos = JSON.parse( + readFileSync(join(root, "src-tauri/tauri.macos.conf.json"), "utf8"), + ); + assert.equal(macos.bundle.macOS.dmg.appPosition.x, 180); + assert.equal(macos.bundle.macOS.dmg.appPosition.y, 170); + assert.equal(macos.bundle.macOS.dmg.applicationFolderPosition.x, 480); + assert.equal(macos.bundle.macOS.dmg.applicationFolderPosition.y, 170); + assert.equal(macos.bundle.macOS.dmg.windowSize.width, 660); + assert.equal(macos.bundle.macOS.dmg.windowSize.height, 400); + assert.equal( + macos.bundle.macOS.dmg.background, + "packaging/macos/dmg-background.png", + ); + assert.equal( + existsSync(join(root, macos.bundle.macOS.dmg.background)), + true, + ); + }); + it("pins one build version and validates exact package metadata", () => { const source = readFileSync(join(root, "build.sh"), "utf8"); assert.match(source, /BUILD_VERSION="\$\(plan version\)"/); diff --git a/scripts/tauri-contract.test.mjs b/scripts/tauri-contract.test.mjs index d5bb1d8..dc21cea 100644 --- a/scripts/tauri-contract.test.mjs +++ b/scripts/tauri-contract.test.mjs @@ -170,6 +170,8 @@ describe("Tauri frontend contract", () => { assert.match(rust, /MenuItem::with_id\(\s*app,\s*"dashboard"/); assert.match(rust, /app.emit\("app-navigate", "dashboard"\)/); assert.match(rust, /apply_tray_menu/); + assert.match(rust, /run_on_main_thread\(move \|\| \{/); + assert.match(rust, /fn replace_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/); @@ -421,7 +423,18 @@ describe("Tauri frontend contract", () => { assert.match(githubUpdate, /\$process\.ExitCode -ne 0/); assert.match(githubUpdate, /1223/); assert.match(rust, /\.try_begin\(/); - assert.match(rust, /phase: "installed"/); + assert.match( + rust, + /ManualRestart =>[\s\S]*?emit_finished_update\([\s\S]*?"installed"/, + ); + assert.match( + rust, + /DiskImageOpened =>[\s\S]*?emit_finished_update\([\s\S]*?"downloaded"/, + ); + const updater = githubUpdate.split("mod tests")[0]; + assert.match(updater, /async fn open_downloaded_dmg/); + assert.doesNotMatch(updater, /fn install_dmg/); + assert.doesNotMatch(updater, /fn dmg_relaunch_script/); assert.doesNotMatch(rust, /fn spawn_background_update_checks\(/); assert.doesNotMatch(rust, /fn fetch_signed_update\(/); assert.doesNotMatch(rust, /open_linux_deb_release/); diff --git a/src-tauri/src/github_update.rs b/src-tauri/src/github_update.rs index 871d317..67703de 100644 --- a/src-tauri/src/github_update.rs +++ b/src-tauri/src/github_update.rs @@ -2,7 +2,9 @@ //! //! Check uses the public Releases API. Install downloads the platform package //! and applies it with `pkexec apt-get` (`.deb`), a replace helper (`AppImage`), -//! or an elevated NSIS installer (Windows). Do not log asset URLs. +//! or an elevated NSIS installer (Windows). On macOS the verified disk image +//! is opened for a drag install and the running app is left in place. Do not +//! log asset URLs. use serde::Deserialize; use std::{ @@ -52,6 +54,12 @@ pub enum ApplyOutcome { ManualRestart, /// A helper script will replace this process after exit. HelperRestart, + /// The signed macOS disk image is open. Nothing was replaced or restarted. + /// + /// Only macOS constructs this variant. Leaving it in the Linux and Windows + /// builds makes Clippy reject it as dead code. + #[cfg(target_os = "macos")] + DiskImageOpened, } #[must_use] @@ -325,12 +333,15 @@ async fn fetch_latest( parse_release(&bytes) } -/// Applies a downloaded package. The caller must pause the stack first. +/// Applies a downloaded package. +/// +/// Linux and Windows callers pause the stack first. A macOS disk image is +/// only opened, so that caller must leave the stack and this process running. /// /// # Errors /// -/// Returns a platform error when `pkexec`, the helper script, or NSIS cannot -/// start. +/// Returns a platform error when `pkexec`, the helper script, NSIS, or +/// `open` cannot start. pub async fn apply_package( kind: InstallKind, package: &Path, @@ -353,11 +364,12 @@ pub async fn apply_package( InstallKind::Dmg => { #[cfg(target_os = "macos")] { - install_dmg(package).await?; - Ok(ApplyOutcome::ManualRestart) + open_downloaded_dmg(package).await?; + Ok(ApplyOutcome::DiskImageOpened) } #[cfg(not(target_os = "macos"))] { + let _ = (package, expected_version); Err("macOS packages cannot be installed on this platform".into()) } } @@ -435,121 +447,33 @@ async fn install_appimage(package: &Path) -> Result<(), String> { Ok(()) } -/// Installs a downloaded `.dmg` on macOS. Mounts the image, copies the bundled -/// `.app` into `/Applications` with administrator privileges, then unmounts. -/// The operator must restart `BiFlow` from `/Applications` afterwards. +/// Opens the verified disk image in Finder. +/// +/// The user drags `BiFlow` onto Applications. This process stays running, so +/// the menu-bar copy keeps the old version until they quit it and open the +/// Applications bundle. #[cfg(target_os = "macos")] -async fn install_dmg(package: &Path) -> Result<(), String> { +async fn open_downloaded_dmg(package: &Path) -> Result<(), String> { info!( - event = "update.dmg_install_started", + event = "update.dmg_opened", section = "updater", initiator = "tauri_command", cause = "verified_package", - trace_route = "ui->apply_app_update->install_dmg", - "installing the verified macOS update" + trace_route = "ui->apply_app_update->open_downloaded_dmg", + "opening the verified disk image for drag install" ); - let mountpoint = std::env::temp_dir().join("biflow-update").join("dmg-mount"); - tokio::fs::create_dir_all(&mountpoint) - .await - .map_err(|error| error.to_string())?; - let attach = tokio::process::Command::new("hdiutil") - .args(["attach", "-nobrowse", "-mountpoint"]) - .arg(&mountpoint) + let status = tokio::process::Command::new("/usr/bin/open") .arg(package) - .output() - .await - .map_err(|error| error.to_string())?; - if !attach.status.success() { - return Err("could not mount the update image".into()); - } - let result = install_mounted_dmg(&mountpoint).await; - let detach = tokio::process::Command::new("hdiutil") - .args(["detach", "-force"]) - .arg(&mountpoint) .status() - .await; - if !detach.is_ok_and(|status| status.success()) { - warn!( - event = "update.dmg_detach_failed", - section = "updater", - initiator = "tauri_command", - cause = "image_detach_failed", - trace_route = "ui->apply_app_update->install_dmg->cleanup", - "could not detach the update image" - ); - } else if tokio::fs::remove_dir(&mountpoint).await.is_err() { - warn!( - event = "update.dmg_mount_cleanup_failed", - section = "updater", - initiator = "tauri_command", - cause = "mount_directory_removal_failed", - trace_route = "ui->apply_app_update->install_dmg->cleanup", - "could not remove the empty update mount directory" - ); - } - info!( - event = "update.dmg_install_finished", - section = "updater", - initiator = "tauri_command", - cause = "installer_returned", - success = result.is_ok(), - trace_route = "ui->apply_app_update->install_dmg", - "macOS update installer finished" - ); - result -} - -#[cfg(target_os = "macos")] -async fn install_mounted_dmg(mountpoint: &Path) -> Result<(), String> { - if !mountpoint.join("BiFlow.app").is_dir() { - return Err("the update image has no BiFlow.app bundle".into()); - } - let script_path = std::env::temp_dir() - .join("biflow-update") - .join("apply-dmg.sh"); - tokio::fs::write(&script_path, dmg_install_script(mountpoint)) .await .map_err(|error| error.to_string())?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = tokio::fs::metadata(&script_path) - .await - .map_err(|error| error.to_string())? - .permissions(); - permissions.set_mode(0o700); - tokio::fs::set_permissions(&script_path, permissions) - .await - .map_err(|error| error.to_string())?; - } - let apple_script = crate::helper_install::macos_admin_applescript(&script_path); - let install = tokio::process::Command::new("osascript") - .args(["-e", &apple_script]) - .output() - .await - .map_err(|error| error.to_string())?; - if install.status.success() { + if status.success() { Ok(()) - } else if install.status.code() == Some(-128) { - Err("package install was cancelled".into()) } else { - Err("package install failed".into()) + Err("could not open the update disk image".into()) } } -#[cfg(any(target_os = "macos", test))] -fn dmg_install_script(mountpoint: &Path) -> String { - format!( - "set -e\n\ - staging=$(mktemp -d '/Applications/.biflow-update.XXXXXX')\n\ - trap 'rm -rf \"$staging\"' EXIT\n\ - cp -R {source} \"$staging/BiFlow.app\"\n\ - rm -rf '/Applications/BiFlow.app'\n\ - mv \"$staging/BiFlow.app\" '/Applications/BiFlow.app'\n", - source = sh_single_quote(&mountpoint.join("BiFlow.app").to_string_lossy()), - ) -} - async fn install_nsis( package: &Path, current_exe: &Path, @@ -821,23 +745,16 @@ mod tests { } #[test] - fn dmg_update_quotes_the_source_and_copies_before_replacing_the_installed_app() { - let script = dmg_install_script(Path::new("/tmp/owner's update image")); - let source = sh_single_quote( - &Path::new("/tmp/owner's update image") - .join("BiFlow.app") - .to_string_lossy(), - ); - assert!(script.contains(&format!("cp -R {source} \"$staging/BiFlow.app\""))); - let copy = script.find("cp -R ").expect("copy"); - let remove = script - .find("rm -rf '/Applications/BiFlow.app'") - .expect("replace"); - assert!( - copy < remove, - "a failed copy must preserve the installed app" - ); - assert!(script.contains("mv \"$staging/BiFlow.app\" '/Applications/BiFlow.app'")); + fn macos_update_opens_the_disk_image_without_replacing_the_app() { + let source = include_str!("github_update.rs"); + let production = source.split("mod tests").next().expect("production half"); + assert!(production.contains("async fn open_downloaded_dmg")); + assert!(production.contains("ApplyOutcome::DiskImageOpened")); + assert!(production.contains("/usr/bin/open")); + assert!(!production.contains("fn install_dmg")); + assert!(!production.contains("fn dmg_relaunch_script")); + assert!(!production.contains("with administrator privileges")); + assert!(!production.contains("/Applications/BiFlow.app")); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fbd9e1d..6099886 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2807,8 +2807,15 @@ fn verify_direct_rules_after_upgrade(data: &Path) -> Result<(), String> { .map_err(|cause| format!("could not clear route-pin upgrade guard: {cause}")) } -async fn restore_stack_after_failed_install(services: &AppServices) { - if matches!(services.engine.snapshot().phase, StackPhase::Paused) { +fn failed_update_requires_resume(kind: github_update::InstallKind, phase: StackPhase) -> bool { + kind != github_update::InstallKind::Dmg && phase == StackPhase::Paused +} + +async fn restore_stack_after_failed_install( + services: &AppServices, + kind: github_update::InstallKind, +) { + if failed_update_requires_resume(kind, services.engine.snapshot().phase) { if let Err(cause) = services.engine.resume_stack().await { warn!( event = "update.stack_restore_failed", @@ -2819,6 +2826,16 @@ async fn restore_stack_after_failed_install(services: &AppServices) { "paused stack could not be restored after a failed install" ); } + } else { + info!( + event = "update.stack_restore_skipped", + section = "updates", + initiator = "install_update", + cause = "no_installer_pause_to_restore", + trace_route = "install_update->restore_stack_after_failed_install", + install_kind = ?kind, + "failed update preserves the existing stack state" + ); } } @@ -2988,19 +3005,18 @@ async fn fetch_verified_update_bytes( } } -async fn apply_downloaded_package( +fn emit_finished_update( app: &AppHandle, operation_id: Uuid, - info: &github_update::UpdateInfo, - package: &Path, -) -> Result { - let target_version = info.latest_version.clone(); + version: String, + phase: &str, +) -> OperationAccepted { emit_update_progress( app, UpdateProgress { - phase: "installing".into(), + phase: phase.into(), percent: Some(100), - version: Some(target_version.clone()), + version: Some(version), error: None, operation_id: Some(operation_id), app_available: None, @@ -3008,55 +3024,72 @@ async fn apply_downloaded_package( thirdparty_available: None, }, ); + OperationAccepted { + operation_id, + already_complete: true, + } +} + +async fn apply_downloaded_package( + app: &AppHandle, + operation_id: Uuid, + info: &github_update::UpdateInfo, + package: &Path, +) -> Result { + let target_version = info.latest_version.clone(); + let kind = github_update::detect_install_kind(); + if kind != github_update::InstallKind::Dmg { + emit_update_progress( + app, + UpdateProgress { + phase: "installing".into(), + percent: Some(100), + version: Some(target_version.clone()), + error: None, + operation_id: Some(operation_id), + app_available: None, + rules_available: None, + thirdparty_available: None, + }, + ); + } let current_exe = std::env::current_exe().map_err(|error| error.to_string())?; - let outcome = match github_update::apply_package( - github_update::detect_install_kind(), - package, - ¤t_exe, - &target_version, - ) - .await - { - Ok(outcome) => outcome, - Err(cause) => { - restore_stack_after_failed_install(services(app)?).await; - emit_update_progress( - app, - UpdateProgress { - phase: "failed".into(), - percent: None, - version: Some(target_version), - error: Some(cause.clone()), - operation_id: Some(operation_id), - app_available: None, - rules_available: None, - thirdparty_available: None, - }, - ); - return Err(cause); - } - }; + let outcome = + match github_update::apply_package(kind, package, ¤t_exe, &target_version).await { + Ok(outcome) => outcome, + Err(cause) => { + restore_stack_after_failed_install(services(app)?, kind).await; + emit_update_progress( + app, + UpdateProgress { + phase: "failed".into(), + percent: None, + version: Some(target_version), + error: Some(cause.clone()), + operation_id: Some(operation_id), + app_available: None, + rules_available: None, + thirdparty_available: None, + }, + ); + return Err(cause); + } + }; record_package_apply_outcome(operation_id, &target_version, outcome); match outcome { - github_update::ApplyOutcome::ManualRestart => { - emit_update_progress( - app, - UpdateProgress { - phase: "installed".into(), - percent: Some(100), - version: Some(target_version), - error: None, - operation_id: Some(operation_id), - app_available: None, - rules_available: None, - thirdparty_available: None, - }, - ); - Ok(OperationAccepted { - operation_id, - already_complete: true, - }) - } + github_update::ApplyOutcome::ManualRestart => Ok(emit_finished_update( + app, + operation_id, + target_version, + "installed", + )), + #[cfg(target_os = "macos")] + github_update::ApplyOutcome::DiskImageOpened => Ok(emit_finished_update( + app, + operation_id, + target_version, + "downloaded", + )), github_update::ApplyOutcome::HelperRestart => { emit_update_progress( app, @@ -3092,6 +3125,17 @@ fn record_package_apply_outcome( update_version = %target_version, "application package installation completed; restart is required" ), + #[cfg(target_os = "macos")] + github_update::ApplyOutcome::DiskImageOpened => info!( + event = "update.dmg_ready", + section = "updates", + initiator = "install_update", + cause = "disk_image_opened", + trace_route = "tauri_command->github_update->open_downloaded_dmg", + trace_id = %operation_id, + update_version = %target_version, + "verified disk image is open; the running app was not replaced" + ), github_update::ApplyOutcome::HelperRestart => info!( event = "update.restart_helper_started", section = "updates", @@ -3137,8 +3181,12 @@ async fn perform_complete_update_install( }); } let package = download_github_package(app, operation_id, &update).await?; - record_direct_rules_upgrade_guard(&services(app)?.paths.data)?; - pause_stack_for_update(services(app)?).await?; + // A disk image is opened for the user to drag. Pausing the tunnel or + // writing an upgrade guard would treat a download as an in-place install. + if github_update::detect_install_kind() != github_update::InstallKind::Dmg { + record_direct_rules_upgrade_guard(&services(app)?.paths.data)?; + pause_stack_for_update(services(app)?).await?; + } apply_downloaded_package(app, operation_id, &update, &package).await } @@ -3663,7 +3711,32 @@ fn build_tray_menu( } fn apply_tray_menu(app: &AppHandle, snapshot: &StackSnapshot) { - let Ok(menu) = build_tray_menu(app, snapshot.phase, snapshot.busy) else { + let key = tray::menu_key(snapshot.phase, snapshot.busy); + if !tray::menu_changed(TRAY_MENU.lock().expect("tray menu").as_ref().copied(), key) { + return; + } + let app = app.clone(); + if let Err(cause) = app.clone().run_on_main_thread(move || { + if !tray::menu_changed(TRAY_MENU.lock().expect("tray menu").as_ref().copied(), key) { + return; + } + if replace_tray_menu(&app, key) { + *TRAY_MENU.lock().expect("tray menu") = Some(key); + } + }) { + warn!( + event = "tray.menu_update_failed", + section = "tray", + initiator = "apply_tray_menu", + cause = %cause, + trace_route = "snapshot_watcher->run_on_main_thread", + "tray menu could not be queued on the main thread" + ); + } +} + +fn replace_tray_menu(app: &AppHandle, key: tray::TrayMenuKey) -> bool { + let Ok(menu) = build_tray_menu(app, key.phase, key.busy) else { warn!( event = "tray.menu_build_failed", section = "tray", @@ -3672,10 +3745,10 @@ fn apply_tray_menu(app: &AppHandle, snapshot: &StackSnapshot) { trace_route = "snapshot_watcher->build_tray_menu", "tray menu could not be rebuilt" ); - return; + return false; }; let Some(icon) = app.tray_by_id("main") else { - return; + return false; }; if let Err(cause) = icon.set_menu(Some(menu)) { warn!( @@ -3686,9 +3759,13 @@ fn apply_tray_menu(app: &AppHandle, snapshot: &StackSnapshot) { trace_route = "snapshot_watcher->tray.set_menu", "tray menu could not be replaced" ); + return false; } + true } +static TRAY_MENU: std::sync::Mutex> = std::sync::Mutex::new(None); + fn setup_tray(app: &tauri::App) -> tauri::Result<()> { let snapshot = app .try_state::() @@ -3706,6 +3783,7 @@ fn setup_tray(app: &tauri::App) -> tauri::Result<()> { .on_tray_icon_event(|tray, event| handle_tray_icon(tray, &event)) .on_menu_event(|app, event| handle_tray_menu(app, &event)) .build(app)?; + *TRAY_MENU.lock().expect("tray menu") = Some(tray::menu_key(snapshot.phase, snapshot.busy)); Ok(()) } @@ -4244,6 +4322,57 @@ mod tests { assert!(!exit_releases_owned_network(true)); } + #[test] + fn failed_disk_image_open_preserves_a_user_paused_stack() { + use github_update::InstallKind; + + assert!( + !failed_update_requires_resume(InstallKind::Dmg, StackPhase::Paused), + "opening a DMG never pauses the stack, so failure must not resume it" + ); + for kind in [InstallKind::Deb, InstallKind::AppImage, InstallKind::Nsis] { + assert!(failed_update_requires_resume(kind, StackPhase::Paused)); + assert!(!failed_update_requires_resume(kind, StackPhase::Running)); + assert!(!failed_update_requires_resume(kind, StackPhase::Stopped)); + } + } + + #[test] + fn macos_disk_image_update_stays_running() { + let source = include_str!("lib.rs"); + let production = source.split("mod tests").next().expect("production half"); + let start = production + .find("async fn perform_complete_update_install") + .expect("install"); + let end = production[start..] + .find("async fn install_update") + .expect("command"); + let body = &production[start..start + end]; + let skip = body + .find("!= github_update::InstallKind::Dmg") + .expect("dmg skip"); + let pause = body.find("pause_stack_for_update").expect("pause"); + assert!( + skip < pause, + "a disk image download must not pause the tunnel" + ); + let apply = production + .find("async fn apply_downloaded_package") + .expect("apply"); + let record = production[apply..] + .find("fn record_package_apply_outcome") + .expect("record"); + let apply_body = &production[apply..apply + record]; + let opened = apply_body.find("DiskImageOpened").expect("opened"); + let helper = apply_body.find("HelperRestart").expect("helper"); + assert!(opened < helper); + assert!(apply_body.contains("\"downloaded\"")); + assert!( + !apply_body[opened..helper].contains("schedule_update_exit"), + "opening a disk image must not quit BiFlow" + ); + } + /// Regression (ADR 0115): the application's data roots and the /// diagnostics log came from two independent reconstructions of /// `BIFLOW_DEV_PROFILE`. Assert they are now one policy, and that a diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 26ab7c3..be76653 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -29,6 +29,27 @@ pub const fn actions_enabled(busy: Option) -> bool { busy.is_none() } +/// Phase and in-flight action that the tray menu already describes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TrayMenuKey { + pub phase: StackPhase, + pub busy: Option, +} + +#[must_use] +pub const fn menu_key(phase: StackPhase, busy: Option) -> TrayMenuKey { + TrayMenuKey { phase, busy } +} + +/// A snapshot that still describes the menu on screen does not need a rebuild. +#[must_use] +pub fn menu_changed(previous: Option, next: TrayMenuKey) -> bool { + match previous { + Some(previous) => previous.phase != next.phase || previous.busy != next.busy, + None => true, + } +} + #[cfg(test)] mod tests { use super::*; @@ -58,6 +79,18 @@ mod tests { assert_eq!(labels.pause_label, "Resume"); } + #[test] + fn menu_rebuilds_only_when_the_phase_or_busy_flag_changes() { + let running = menu_key(StackPhase::Running, None); + assert!(menu_changed(None, running)); + assert!(!menu_changed(Some(running), running)); + let connecting = menu_key(StackPhase::Running, Some(LifecycleBusy::Connecting)); + assert!(menu_changed(Some(running), connecting)); + assert!(!menu_changed(Some(connecting), connecting)); + let paused = menu_key(StackPhase::Paused, None); + assert!(menu_changed(Some(running), paused)); + } + #[test] fn never_emits_both_options_from_the_same_pair() { for phase in [ diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 68e8441..a2a754a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "BiFlow", - "version": "6.2.75", + "version": "6.2.80", "identifier": "app.biflow.desktop", "build": { "beforeDevCommand": "pnpm bundle:check && pnpm --dir apps/desktop dev", diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 43ef429..59f3bea 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -3,7 +3,22 @@ "macOS": { "minimumSystemVersion": "11.0", "signingIdentity": null, - "hardenedRuntime": true + "hardenedRuntime": true, + "dmg": { + "background": "packaging/macos/dmg-background.png", + "windowSize": { + "width": 660, + "height": 400 + }, + "appPosition": { + "x": 180, + "y": 170 + }, + "applicationFolderPosition": { + "x": 480, + "y": 170 + } + } }, "resources": { "../vendor/mihomo/darwin/mihomo": "dependencies/mihomo", diff --git a/version b/version index 69b768b..6d2db09 100644 --- a/version +++ b/version @@ -1 +1 @@ -6.2.75 +6.2.80