From 23dda4764108ae5d7e6ce998989fe0b4a55dfc24 Mon Sep 17 00:00:00 2001 From: wonfen <96291150+wonfen@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:01:49 +0800 Subject: [PATCH] feat: add launch guard and secure multi-user service ownership (#7569) * feat(service): isolate macOS launch and owner runtime * ci(windows): run owner security tests * chore(deps): use kode-bridge v0.4.1 tag * fix(macos): classify installed service state * fix(macos): classify marked unavailable service * fix(macos): gate core startup on service migration * fix(macos): scope service init to supported platforms * feat(macos): prompt before migrating installed service * fix(macos): shorten sidecar IPC path * build(service): pin helper protocol and artifacts * fix(core): require confirmed service and sidecar readiness * fix(macos): harden launch roots and singleton state * fix(ui): refresh migration state before restart * fix(macos): linearize service migration lifecycle * fix(ui): keep service migration state fresh * feat(service): track authenticated owner sessions * fix(service): preserve newer owner sessions * feat(macos): route service proxy changes through helper * fix(macos): serialize service proxy guard * fix(macos): close service proxy owner lifecycle * fix(macos): close remaining proxy stop races * fix(app): do not cancel controlled service stop * fix(lifecycle): keep controlled updates cancellation-safe * fix(macos): stage dev core for service * test(macos): exercise staged core cleanup * fix(dev): await controlled macOS exit * chore: update changelog * fix(macos): recover sidecar after service install failure Allow the existing sidecar to continue when service installation leaves the manager in InstallRequired, then settle the session state as SidecarAllowed. Show neutral status text while the migration dialog waits for its final state. * fix(macos): stage dev service tools before elevation * fix(macos): localize launch guard prompts * fix(service): address cross-platform integration issues * feat: support seamless service migration * fix: pass Windows service installer path safely * fix(prebuild): derive service release from Cargo dependency * fix(dev): preserve service installation state * fix: resolve clippy warnings in service lifecycle --- .github/workflows/lint-clippy.yml | 5 + Cargo.lock | 58 +- Cargo.toml | 3 + Changelog.md | 5 + README.md | 6 + crates/clash-verge-i18n/locales/ar.yml | 21 +- crates/clash-verge-i18n/locales/de.yml | 21 +- crates/clash-verge-i18n/locales/en.yml | 21 +- crates/clash-verge-i18n/locales/es.yml | 21 +- crates/clash-verge-i18n/locales/fa.yml | 21 +- crates/clash-verge-i18n/locales/id.yml | 21 +- crates/clash-verge-i18n/locales/jp.yml | 21 +- crates/clash-verge-i18n/locales/ko.yml | 21 +- crates/clash-verge-i18n/locales/ru.yml | 21 +- crates/clash-verge-i18n/locales/tr.yml | 21 +- crates/clash-verge-i18n/locales/tt.yml | 21 +- crates/clash-verge-i18n/locales/zh.yml | 21 +- crates/clash-verge-i18n/locales/zhtw.yml | 21 +- crates/clash-verge-signal/Cargo.toml | 2 +- crates/clash-verge-signal/src/lib.rs | 65 +- crates/clash-verge-signal/src/unix.rs | 14 +- crates/clash-verge-signal/src/windows.rs | 14 +- package.json | 10 +- pnpm-lock.yaml | 10 - scripts/dev-control.mjs | 280 +++ scripts/dev-control.test.mjs | 1167 +++++++++ scripts/dev-service.mjs | 190 ++ scripts/dev.mjs | 565 +++++ scripts/prebuild.mjs | 73 +- scripts/service-release.mjs | 23 + scripts/service-release.test.mjs | 39 + src-tauri/Cargo.toml | 18 +- src-tauri/capabilities/desktop.json | 1 - src-tauri/capabilities/migrated.json | 1 - src-tauri/src/cmd/network.rs | 5 + src-tauri/src/cmd/profile.rs | 88 +- src-tauri/src/cmd/service.rs | 37 +- src-tauri/src/config/clash.rs | 4 +- src-tauri/src/config/config.rs | 53 +- src-tauri/src/config/verge.rs | 4 - src-tauri/src/constants.rs | 5 - src-tauri/src/core/handle.rs | 4 + src-tauri/src/core/logger.rs | 23 +- src-tauri/src/core/manager/config.rs | 23 +- src-tauri/src/core/manager/lifecycle.rs | 1087 ++++++++- src-tauri/src/core/manager/mod.rs | 79 +- src-tauri/src/core/manager/state.rs | 248 +- src-tauri/src/core/mod.rs | 3 + src-tauri/src/core/owner_identity.rs | 575 +++++ src-tauri/src/core/proxy_control.rs | 503 ++++ src-tauri/src/core/runtime_bundle.rs | 250 ++ src-tauri/src/core/service.rs | 2129 +++++++++++++++-- src-tauri/src/core/sysopt.rs | 52 +- src-tauri/src/core/timer.rs | 153 +- src-tauri/src/core/tray/mod.rs | 5 +- src-tauri/src/enhance/mod.rs | 2 +- src-tauri/src/feat/clash.rs | 13 +- src-tauri/src/feat/config.rs | 7 +- src-tauri/src/feat/window.rs | 466 +++- src-tauri/src/lib.rs | 24 +- src-tauri/src/utils/dirs.rs | 220 +- src-tauri/src/utils/help.rs | 14 +- src-tauri/src/utils/macos_launch_guard.rs | 528 ++++ src-tauri/src/utils/mod.rs | 2 + src-tauri/src/utils/resolve/mod.rs | 35 +- src-tauri/src/utils/server.rs | 604 ++++- .../layout/service-migration-dialog.tsx | 194 ++ .../setting/mods/sysproxy-viewer.tsx | 16 +- src/components/setting/mods/update-viewer.tsx | 4 +- .../shared/proxy-control-switches.tsx | 8 +- src/hooks/use-service-uninstaller.ts | 55 +- src/hooks/use-system-proxy-state.ts | 8 +- src/hooks/use-system-state.ts | 69 +- src/locales/ar/layout.json | 10 + src/locales/de/layout.json | 10 + src/locales/en/layout.json | 10 + src/locales/es/layout.json | 10 + src/locales/fa/layout.json | 10 + src/locales/id/layout.json | 10 + src/locales/jp/layout.json | 10 + src/locales/ko/layout.json | 10 + src/locales/ru/layout.json | 10 + src/locales/tr/layout.json | 10 + src/locales/tt/layout.json | 10 + src/locales/zh/layout.json | 10 + src/locales/zhtw/layout.json | 10 + src/pages/_layout.tsx | 2 + src/services/cmds.ts | 40 +- src/types/generated/i18n-keys.ts | 8 + src/types/generated/i18n-resources.ts | 10 + 90 files changed, 9711 insertions(+), 900 deletions(-) create mode 100644 scripts/dev-control.mjs create mode 100644 scripts/dev-control.test.mjs create mode 100644 scripts/dev-service.mjs create mode 100644 scripts/dev.mjs create mode 100644 scripts/service-release.mjs create mode 100644 scripts/service-release.test.mjs create mode 100644 src-tauri/src/core/owner_identity.rs create mode 100644 src-tauri/src/core/proxy_control.rs create mode 100644 src-tauri/src/core/runtime_bundle.rs create mode 100644 src-tauri/src/utils/macos_launch_guard.rs create mode 100644 src/components/layout/service-migration-dialog.tsx diff --git a/.github/workflows/lint-clippy.yml b/.github/workflows/lint-clippy.yml index 6a5499bf1..37e75aab6 100644 --- a/.github/workflows/lint-clippy.yml +++ b/.github/workflows/lint-clippy.yml @@ -78,6 +78,11 @@ jobs: working-directory: ./src-tauri run: cargo clippy-all + - name: Run Windows security tests + if: matrix.os == 'windows-latest' && (github.event_name == 'workflow_dispatch' || steps.check_changes.outputs.rust == 'true') + working-directory: ./src-tauri + run: cargo test --all-features --target ${{ matrix.target }} + - name: Run Logging Check working-directory: ./src-tauri shell: bash diff --git a/Cargo.lock b/Cargo.lock index 851d33d8f..cc4f339b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1101,6 +1101,7 @@ dependencies = [ "futures", "gethostname", "getrandom 0.4.3", + "libc", "log", "nanoid", "network-interface", @@ -1148,6 +1149,7 @@ dependencies = [ "warp", "webpki-roots", "windows 0.62.2", + "windows-service", "windows-sys 0.61.2", "winreg 0.56.0", "zip 8.6.0", @@ -1209,19 +1211,25 @@ dependencies = [ [[package]] name = "clash_verge_service_ipc" -version = "2.3.3" -source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc#b964ed2992599fadefd589425c1acdabcb875623" +version = "2.5.2" +source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.5.2#861865de99aa2a2fe8b359f5a2d550dfe5662005" dependencies = [ "anyhow", "compact_str", "kode-bridge", + "libc", "log", "once_cell", "serde", "serde_json", + "sha2 0.10.9", "strum", "strum_macros", + "sysproxy", "tokio", + "url", + "windows-service", + "windows-sys 0.61.2", ] [[package]] @@ -1284,7 +1292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -1988,7 +1996,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2259,7 +2267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3332,7 +3340,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3798,9 +3806,8 @@ dependencies = [ [[package]] name = "kode-bridge" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c462fd5d0886cd98cd74ecafdb44dd3f2b52b30711f07d42cc0f5956f38e706d" +version = "0.4.2" +source = "git+https://github.com/clash-verge-rev/kode-bridge.git?branch=dev#fbd9888431cff8fba30934c9fbe1a553fce1ef1b" dependencies = [ "bytes", "futures", @@ -3822,6 +3829,7 @@ dependencies = [ "tracing", "url", "widestring", + "windows-sys 0.61.2", ] [[package]] @@ -4146,7 +4154,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4313,7 +4321,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5987,7 +5995,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6046,7 +6054,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6671,7 +6679,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6874,7 +6882,7 @@ dependencies = [ [[package]] name = "sysproxy" version = "0.5.4" -source = "git+https://github.com/clash-verge-rev/sysproxy-rs#d214eba9f61d655d49a35f6ae17c359f7ad68d79" +source = "git+https://github.com/clash-verge-rev/sysproxy-rs?branch=main#d214eba9f61d655d49a35f6ae17c359f7ad68d79" dependencies = [ "iptools", "log", @@ -7554,7 +7562,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8273,7 +8281,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8375,7 +8383,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8599,6 +8607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0a808122a8a77eecdabaefd88ddb1913c4be5ea1465399f63ba64c7aa705fea" dependencies = [ "bytes", + "futures-channel", "futures-util", "headers", "http 1.4.2", @@ -9159,6 +9168,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-service" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2" +dependencies = [ + "bitflags 2.13.0", + "widestring", + "windows-sys 0.61.2", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -9554,7 +9574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f65b670b9..ee7bf1805 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,9 @@ bitflags = { version = "2.13.0" } deelevate = "0.2.0" windows-sys = { version = "0.61.2", features = [ "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_System_JobObjects" ] } diff --git a/Changelog.md b/Changelog.md index d84e585e5..57e02ccc2 100644 --- a/Changelog.md +++ b/Changelog.md @@ -12,5 +12,10 @@ - TUN 模式设置也遵循 原始配置 < Merge 覆写 < Script 脚本 < GUI - 优化代理数据处理:统一通过单一 IPC 获取有序代理视图,并增强刷新、链路选择、重复判断及异常状态下的稳定性 +- 新增 macOS 启动门禁,避免多用户环境下的实例冲突 +- 加强 Service 多用户隔离与身份认证,支持安全的 Owner 接管 +- 统一系统代理所有权,实现原子化代理切换 +- 新增旧版 Service 迁移引导与 Sidecar 安全回退 +- 优化核心启动、停止及更新流程,减少竞态与残留进程 diff --git a/README.md b/README.md index 0561f10ea..27fa217e4 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,12 @@ pnpm run prebuild pnpm dev ``` +`pnpm dev` preserves the Development Channel's installed service state: an +existing service is used, while a previously uninstalled service remains +uninstalled and the app starts in Sidecar mode. Use `pnpm dev:service` to +explicitly install or update the isolated development service before launch, +or `pnpm dev:sidecar` to force the unprivileged Sidecar workflow. + ## Contributions Issue and PR welcome! diff --git a/crates/clash-verge-i18n/locales/ar.yml b/crates/clash-verge-i18n/locales/ar.yml index 89bd43097..d408d9efa 100644 --- a/crates/clash-verge-i18n/locales/ar.yml +++ b/crates/clash-verge-i18n/locales/ar.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول. adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول. - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: 'تعذر تحديد موقع التطبيق: {error}' + homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}' + translocated: يشغّل macOS هذا التطبيق من مسار App Translocation مؤقت. انقله يدويًا في Finder إلى /Applications أو ~/Applications، ثم افتحه مجددًا. + rejected: |- + يجب تشغيل هذا التطبيق من مجلد Applications. + + {reason} + notInBundle: الملف التنفيذي ليس داخل حزمة تطبيق. + canonicalizeBundleError: 'تعذر تحديد المسار الفعلي لحزمة التطبيق: {error}' + bundleNameError: تعذر تحديد اسم حزمة التطبيق. سيتم إنهاء التطبيق. + replacePrompt: يوجد تطبيق بالفعل في {destination}. هل تريد استبداله وإعادة تشغيله من Applications؟ + movePrompt: لتشغيل خدمة الخلفية بأمان، يجب نقل التطبيق إلى {destination}. هل تريد نقله وإعادة تشغيله الآن؟ + createApplicationsDirError: 'تعذر إنشاء مجلد Applications: {error}' + moveFailed: فشل نقل التطبيق. انقله يدويًا في Finder ثم أعد المحاولة. + replaceFailed: 'تعذر استبدال التطبيق الموجود بأمان: {error}' + relaunchFailed: تم نسخ التطبيق، ولكن تعذرت إعادة تشغيله تلقائيًا. افتحه يدويًا من Applications. + cancel: إلغاء + move: نقل + ok: موافق tray: dashboard: لوحة التحكم ruleMode: وضع القواعد diff --git a/crates/clash-verge-i18n/locales/de.yml b/crates/clash-verge-i18n/locales/de.yml index b0991350f..1421706ff 100644 --- a/crates/clash-verge-i18n/locales/de.yml +++ b/crates/clash-verge-i18n/locales/de.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich. adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich. - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}' + homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}' + translocated: macOS führt diese Anwendung aus einem temporären App-Translocation-Pfad aus. Verschieben Sie sie im Finder manuell nach /Applications oder ~/Applications und öffnen Sie sie anschließend erneut. + rejected: |- + Diese Anwendung muss aus einem Applications-Ordner gestartet werden. + + {reason} + notInBundle: Die ausführbare Datei befindet sich nicht in einem Anwendungspaket. + canonicalizeBundleError: 'Der Pfad des Anwendungspakets konnte nicht aufgelöst werden: {error}' + bundleNameError: Der Name des Anwendungspakets konnte nicht ermittelt werden. Die Anwendung wird beendet. + replacePrompt: Unter {destination} ist bereits eine Anwendung vorhanden. Soll sie ersetzt und aus Applications neu gestartet werden? + movePrompt: Damit der Hintergrunddienst sicher gestartet werden kann, muss die Anwendung nach {destination} verschoben werden. Jetzt verschieben und neu starten? + createApplicationsDirError: 'Der Applications-Ordner konnte nicht erstellt werden: {error}' + moveFailed: Die Anwendung konnte nicht verschoben werden. Verschieben Sie sie im Finder manuell und versuchen Sie es erneut. + replaceFailed: 'Die vorhandene Anwendung konnte nicht sicher ersetzt werden: {error}' + relaunchFailed: Die Anwendung wurde kopiert, konnte aber nicht automatisch neu gestartet werden. Öffnen Sie sie manuell aus Applications. + cancel: Abbrechen + move: Verschieben + ok: OK tray: dashboard: Übersicht ruleMode: Regelmodus diff --git a/crates/clash-verge-i18n/locales/en.yml b/crates/clash-verge-i18n/locales/en.yml index 57cfe0598..7df4a95b7 100644 --- a/crates/clash-verge-i18n/locales/en.yml +++ b/crates/clash-verge-i18n/locales/en.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: 'Unable to determine the application location: {error}' + homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}' + translocated: macOS is running this application from a temporary App Translocation path. In Finder, move it manually to /Applications or ~/Applications, then reopen it. + rejected: |- + This application must be launched from an Applications directory. + + {reason} + notInBundle: The executable is not inside an application bundle. + canonicalizeBundleError: 'Unable to resolve the application bundle path: {error}' + bundleNameError: Unable to determine the application bundle name. The application will exit. + replacePrompt: An application already exists at {destination}. Replace it and relaunch from Applications? + movePrompt: To start the background service safely, the application must be moved to {destination}. Move and relaunch it now? + createApplicationsDirError: 'Unable to create the Applications directory: {error}' + moveFailed: Failed to move the application. Move it manually in Finder and try again. + replaceFailed: 'Unable to replace the existing application safely: {error}' + relaunchFailed: The application was copied, but it could not be relaunched automatically. Open it manually from Applications. + cancel: Cancel + move: Move + ok: OK tray: dashboard: Dashboard ruleMode: Rule Mode diff --git a/crates/clash-verge-i18n/locales/es.yml b/crates/clash-verge-i18n/locales/es.yml index 578bafb74..cd9ee3710 100644 --- a/crates/clash-verge-i18n/locales/es.yml +++ b/crates/clash-verge-i18n/locales/es.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador. adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador. - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}' + homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}' + translocated: macOS está ejecutando esta aplicación desde una ruta temporal de App Translocation. Muévela manualmente en Finder a /Applications o ~/Applications y vuelve a abrirla. + rejected: |- + Esta aplicación debe iniciarse desde una carpeta Applications. + + {reason} + notInBundle: El archivo ejecutable no está dentro de un paquete de aplicación. + canonicalizeBundleError: 'No se pudo resolver la ruta del paquete de la aplicación: {error}' + bundleNameError: No se pudo determinar el nombre del paquete de la aplicación. La aplicación se cerrará. + replacePrompt: Ya existe una aplicación en {destination}. ¿Quieres reemplazarla y reiniciarla desde Applications? + movePrompt: Para iniciar el servicio en segundo plano de forma segura, la aplicación debe moverse a {destination}. ¿Quieres moverla y reiniciarla ahora? + createApplicationsDirError: 'No se pudo crear la carpeta Applications: {error}' + moveFailed: No se pudo mover la aplicación. Muévela manualmente en Finder e inténtalo de nuevo. + replaceFailed: 'No se pudo reemplazar de forma segura la aplicación existente: {error}' + relaunchFailed: La aplicación se copió, pero no se pudo reiniciar automáticamente. Ábrela manualmente desde Applications. + cancel: Cancelar + move: Mover + ok: Aceptar tray: dashboard: Panel ruleMode: Modo de reglas diff --git a/crates/clash-verge-i18n/locales/fa.yml b/crates/clash-verge-i18n/locales/fa.yml index f08e72791..9ce9b2bac 100644 --- a/crates/clash-verge-i18n/locales/fa.yml +++ b/crates/clash-verge-i18n/locales/fa.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد. adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد. - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: 'مکان برنامه مشخص نشد: {error}' + homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}' + translocated: macOS این برنامه را از یک مسیر موقت App Translocation اجرا می‌کند. آن را در Finder به‌صورت دستی به /Applications یا ~/Applications منتقل کنید و دوباره باز کنید. + rejected: |- + این برنامه باید از پوشهٔ Applications اجرا شود. + + {reason} + notInBundle: فایل اجرایی داخل بستهٔ برنامه نیست. + canonicalizeBundleError: 'مسیر بستهٔ برنامه قابل تشخیص نیست: {error}' + bundleNameError: نام بستهٔ برنامه مشخص نشد. برنامه بسته خواهد شد. + replacePrompt: در {destination} از قبل برنامه‌ای وجود دارد. جایگزین و از Applications دوباره اجرا شود؟ + movePrompt: برای اجرای امن سرویس پس‌زمینه، برنامه باید به {destination} منتقل شود. اکنون منتقل و دوباره اجرا شود؟ + createApplicationsDirError: 'پوشهٔ Applications ایجاد نشد: {error}' + moveFailed: انتقال برنامه ناموفق بود. آن را در Finder به‌صورت دستی منتقل کنید و دوباره تلاش کنید. + replaceFailed: 'برنامهٔ موجود به‌صورت امن جایگزین نشد: {error}' + relaunchFailed: برنامه کپی شد، اما اجرای دوبارهٔ خودکار ناموفق بود. آن را به‌صورت دستی از Applications باز کنید. + cancel: لغو + move: انتقال + ok: تأیید tray: dashboard: داشبورد ruleMode: حالت قوانین diff --git a/crates/clash-verge-i18n/locales/id.yml b/crates/clash-verge-i18n/locales/id.yml index 03435d9de..0ac806901 100644 --- a/crates/clash-verge-i18n/locales/id.yml +++ b/crates/clash-verge-i18n/locales/id.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator. adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator. - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}' + homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}' + translocated: macOS menjalankan aplikasi ini dari jalur App Translocation sementara. Pindahkan secara manual di Finder ke /Applications atau ~/Applications, lalu buka kembali. + rejected: |- + Aplikasi ini harus dijalankan dari direktori Applications. + + {reason} + notInBundle: Berkas yang dapat dijalankan tidak berada di dalam bundel aplikasi. + canonicalizeBundleError: 'Jalur bundel aplikasi tidak dapat diuraikan: {error}' + bundleNameError: Nama bundel aplikasi tidak dapat ditentukan. Aplikasi akan ditutup. + replacePrompt: Sudah ada aplikasi di {destination}. Ganti dan jalankan ulang dari Applications? + movePrompt: Agar layanan latar belakang dapat dimulai dengan aman, aplikasi harus dipindahkan ke {destination}. Pindahkan dan jalankan ulang sekarang? + createApplicationsDirError: 'Direktori Applications tidak dapat dibuat: {error}' + moveFailed: Aplikasi gagal dipindahkan. Pindahkan secara manual di Finder lalu coba lagi. + replaceFailed: 'Aplikasi yang ada tidak dapat diganti dengan aman: {error}' + relaunchFailed: Aplikasi telah disalin, tetapi tidak dapat dijalankan ulang secara otomatis. Buka secara manual dari Applications. + cancel: Batal + move: Pindahkan + ok: OK tray: dashboard: Dasbor ruleMode: Mode Aturan diff --git a/crates/clash-verge-i18n/locales/jp.yml b/crates/clash-verge-i18n/locales/jp.yml index a0e39333f..c789d268d 100644 --- a/crates/clash-verge-i18n/locales/jp.yml +++ b/crates/clash-verge-i18n/locales/jp.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。 adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。 - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: アプリケーションの場所を確認できませんでした:{error} + homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error} + translocated: macOS が一時的な App Translocation パスからこのアプリケーションを実行しています。Finder で /Applications または ~/Applications に手動で移動してから、もう一度開いてください。 + rejected: |- + このアプリケーションは Applications ディレクトリから起動する必要があります。 + + {reason} + notInBundle: 実行ファイルがアプリケーションバンドル内にありません。 + canonicalizeBundleError: アプリケーションバンドルのパスを解決できませんでした:{error} + bundleNameError: アプリケーションバンドル名を確認できないため、アプリケーションを終了します。 + replacePrompt: '{destination} には既にアプリケーションがあります。置き換えて Applications から再起動しますか?' + movePrompt: バックグラウンドサービスを安全に起動するには、アプリケーションを {destination} に移動する必要があります。今すぐ移動して再起動しますか? + createApplicationsDirError: Applications ディレクトリを作成できませんでした:{error} + moveFailed: アプリケーションを移動できませんでした。Finder で手動で移動してから、もう一度お試しください。 + replaceFailed: 既存のアプリケーションを安全に置き換えられませんでした:{error} + relaunchFailed: アプリケーションはコピーされましたが、自動的に再起動できませんでした。Applications から手動で開いてください。 + cancel: キャンセル + move: 移動 + ok: OK tray: dashboard: ダッシュボード ruleMode: ルールモード diff --git a/crates/clash-verge-i18n/locales/ko.yml b/crates/clash-verge-i18n/locales/ko.yml index 9255d3a41..1a9ba8b23 100644 --- a/crates/clash-verge-i18n/locales/ko.yml +++ b/crates/clash-verge-i18n/locales/ko.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다. adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다. - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}' + homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}' + translocated: macOS가 임시 App Translocation 경로에서 이 애플리케이션을 실행하고 있습니다. Finder에서 /Applications 또는 ~/Applications로 직접 이동한 후 다시 여십시오. + rejected: |- + 이 애플리케이션은 Applications 디렉터리에서 실행해야 합니다. + + {reason} + notInBundle: 실행 파일이 애플리케이션 번들 안에 없습니다. + canonicalizeBundleError: '애플리케이션 번들 경로를 확인할 수 없습니다: {error}' + bundleNameError: 애플리케이션 번들 이름을 확인할 수 없어 애플리케이션을 종료합니다. + replacePrompt: '{destination}에 애플리케이션이 이미 있습니다. 교체하고 Applications에서 다시 실행하시겠습니까?' + movePrompt: 백그라운드 서비스를 안전하게 시작하려면 애플리케이션을 {destination}(으)로 이동해야 합니다. 지금 이동하고 다시 실행하시겠습니까? + createApplicationsDirError: 'Applications 디렉터리를 만들 수 없습니다: {error}' + moveFailed: 애플리케이션을 이동하지 못했습니다. Finder에서 직접 이동한 후 다시 시도하십시오. + replaceFailed: '기존 애플리케이션을 안전하게 교체할 수 없습니다: {error}' + relaunchFailed: 애플리케이션을 복사했지만 자동으로 다시 실행하지 못했습니다. Applications에서 직접 여십시오. + cancel: 취소 + move: 이동 + ok: 확인 tray: dashboard: 대시보드 ruleMode: 규칙 모드 diff --git a/crates/clash-verge-i18n/locales/ru.yml b/crates/clash-verge-i18n/locales/ru.yml index 6563e0af3..1af6f254d 100644 --- a/crates/clash-verge-i18n/locales/ru.yml +++ b/crates/clash-verge-i18n/locales/ru.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: Для установки службы Clash Verge требуются права администратора. adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора. - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: 'Не удалось определить расположение приложения: {error}' + homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}' + translocated: macOS запускает это приложение из временного пути App Translocation. Переместите его вручную в Finder в /Applications или ~/Applications, затем откройте снова. + rejected: |- + Это приложение необходимо запускать из каталога Applications. + + {reason} + notInBundle: Исполняемый файл не находится внутри пакета приложения. + canonicalizeBundleError: 'Не удалось определить путь к пакету приложения: {error}' + bundleNameError: Не удалось определить имя пакета приложения. Приложение будет закрыто. + replacePrompt: В {destination} уже есть приложение. Заменить его и перезапустить из Applications? + movePrompt: Для безопасного запуска фоновой службы приложение необходимо переместить в {destination}. Переместить и перезапустить сейчас? + createApplicationsDirError: 'Не удалось создать каталог Applications: {error}' + moveFailed: Не удалось переместить приложение. Переместите его вручную в Finder и повторите попытку. + replaceFailed: 'Не удалось безопасно заменить существующее приложение: {error}' + relaunchFailed: Приложение скопировано, но его не удалось перезапустить автоматически. Откройте его вручную из Applications. + cancel: Отмена + move: Переместить + ok: ОК tray: dashboard: Панель ruleMode: Режим правил diff --git a/crates/clash-verge-i18n/locales/tr.yml b/crates/clash-verge-i18n/locales/tr.yml index da201fabb..4a44466a9 100644 --- a/crates/clash-verge-i18n/locales/tr.yml +++ b/crates/clash-verge-i18n/locales/tr.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir. adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir. - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: 'Uygulamanın konumu belirlenemedi: {error}' + homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}' + translocated: macOS bu uygulamayı geçici bir App Translocation yolundan çalıştırıyor. Finder'da uygulamayı elle /Applications veya ~/Applications dizinine taşıyın ve yeniden açın. + rejected: |- + Bu uygulama bir Applications dizininden başlatılmalıdır. + + {reason} + notInBundle: Yürütülebilir dosya bir uygulama paketinin içinde değil. + canonicalizeBundleError: 'Uygulama paketinin yolu çözümlenemedi: {error}' + bundleNameError: Uygulama paketinin adı belirlenemedi. Uygulama kapatılacak. + replacePrompt: '{destination} konumunda zaten bir uygulama var. Değiştirilsin ve Applications üzerinden yeniden başlatılsın mı?' + movePrompt: Arka plan hizmetini güvenli şekilde başlatmak için uygulamanın {destination} konumuna taşınması gerekir. Şimdi taşınıp yeniden başlatılsın mı? + createApplicationsDirError: 'Applications dizini oluşturulamadı: {error}' + moveFailed: Uygulama taşınamadı. Finder'da elle taşıyıp tekrar deneyin. + replaceFailed: 'Mevcut uygulama güvenli şekilde değiştirilemedi: {error}' + relaunchFailed: Uygulama kopyalandı ancak otomatik olarak yeniden başlatılamadı. Applications üzerinden elle açın. + cancel: İptal + move: Taşı + ok: Tamam tray: dashboard: Gösterge Paneli ruleMode: Kural Modu diff --git a/crates/clash-verge-i18n/locales/tt.yml b/crates/clash-verge-i18n/locales/tt.yml index 6a96c0e25..62a3e0800 100644 --- a/crates/clash-verge-i18n/locales/tt.yml +++ b/crates/clash-verge-i18n/locales/tt.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк. adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк. - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}' + homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}' + translocated: macOS бу кушымтаны вакытлы App Translocation юлыннан эшләтә. Finder аша аны /Applications яки ~/Applications каталогына кулдан күчерегез һәм яңадан ачыгыз. + rejected: |- + Бу кушымта Applications каталогыннан эшләтелергә тиеш. + + {reason} + notInBundle: Башкарылучы файл кушымта пакетында түгел. + canonicalizeBundleError: 'Кушымта пакетының юлын билгеләп булмады: {error}' + bundleNameError: Кушымта пакетының исемен билгеләп булмады. Кушымта ябылачак. + replacePrompt: '{destination} урынында кушымта бар инде. Аны алыштырып, Applications аша яңадан эшләтергәме?' + movePrompt: Фон хезмәтен имин эшләтү өчен кушымтаны {destination} урынына күчерергә кирәк. Хәзер күчереп, яңадан эшләтергәме? + createApplicationsDirError: 'Applications каталогын төзеп булмады: {error}' + moveFailed: Кушымтаны күчереп булмады. Аны Finder аша кулдан күчереп, кабатлап карагыз. + replaceFailed: 'Булган кушымтаны имин алыштырып булмады: {error}' + relaunchFailed: Кушымта күчерелде, ләкин автоматик рәвештә яңадан эшләтеп булмады. Аны Applications аша кулдан ачыгыз. + cancel: Баш тарту + move: Күчерү + ok: Ярый tray: dashboard: Идарә панеле ruleMode: Кагыйдә режимы diff --git a/crates/clash-verge-i18n/locales/zh.yml b/crates/clash-verge-i18n/locales/zh.yml index da380a22e..fe7922c1a 100644 --- a/crates/clash-verge-i18n/locales/zh.yml +++ b/crates/clash-verge-i18n/locales/zh.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限 adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限 - translocatedCorePath: 检测到 Clash Verge 从临时路径(App Translocation)运行,无法定位内核。请将 Clash Verge 移动到「应用程序」文件夹后重新打开。 +launchGuard: + currentExeError: 无法确定应用位置:{error} + homeDirError: 无法确定当前用户主目录,应用将退出:{error} + translocated: macOS 正在从 App Translocation 临时路径运行此应用。请在 Finder 中将应用手动拖到 /Applications 或 ~/Applications 后重新打开。 + rejected: |- + 此应用必须从 Applications 目录启动。 + + {reason} + notInBundle: 可执行文件不在应用包内。 + canonicalizeBundleError: 无法解析应用包路径:{error} + bundleNameError: 无法确定应用包名称,应用将退出。 + replacePrompt: 目标位置已存在应用:{destination}。是否替换并从 Applications 重新启动? + movePrompt: 为了安全启动后台服务,需要将应用移动到 {destination}。是否现在移动并重新启动? + createApplicationsDirError: 无法创建 Applications 目录:{error} + moveFailed: 移动应用失败。请在 Finder 中手动移动后重试。 + replaceFailed: 无法安全替换现有应用:{error} + relaunchFailed: 应用已复制,但自动重新启动失败。请从 Applications 手动打开。 + cancel: 取消 + move: 移动 + ok: 好 tray: dashboard: 仪表板 ruleMode: 规则模式 diff --git a/crates/clash-verge-i18n/locales/zhtw.yml b/crates/clash-verge-i18n/locales/zhtw.yml index adbef4490..bc03caa47 100644 --- a/crates/clash-verge-i18n/locales/zhtw.yml +++ b/crates/clash-verge-i18n/locales/zhtw.yml @@ -34,7 +34,26 @@ notifications: service: adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限 adminUninstallPrompt: 卸载 Clash Verge 服務需要管理員權限 - translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it. +launchGuard: + currentExeError: 無法判斷應用程式位置:{error} + homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error} + translocated: macOS 正從 App Translocation 暫存路徑執行此應用程式。請在 Finder 中將應用程式手動移至 /Applications 或 ~/Applications,然後重新開啟。 + rejected: |- + 此應用程式必須從 Applications 目錄啟動。 + + {reason} + notInBundle: 可執行檔不在應用程式套件內。 + canonicalizeBundleError: 無法解析應用程式套件路徑:{error} + bundleNameError: 無法判斷應用程式套件名稱,應用程式將結束。 + replacePrompt: 目標位置已有應用程式:{destination}。是否取代並從 Applications 重新啟動? + movePrompt: 為了安全啟動背景服務,需要將應用程式移至 {destination}。是否立即移動並重新啟動? + createApplicationsDirError: 無法建立 Applications 目錄:{error} + moveFailed: 移動應用程式失敗。請在 Finder 中手動移動後再試一次。 + replaceFailed: 無法安全取代現有應用程式:{error} + relaunchFailed: 應用程式已複製,但無法自動重新啟動。請從 Applications 手動開啟。 + cancel: 取消 + move: 移動 + ok: 好 tray: dashboard: 儀表板 ruleMode: 規則模式 diff --git a/crates/clash-verge-signal/Cargo.toml b/crates/clash-verge-signal/Cargo.toml index ef3b12983..f8a03ac48 100644 --- a/crates/clash-verge-signal/Cargo.toml +++ b/crates/clash-verge-signal/Cargo.toml @@ -7,7 +7,7 @@ rust-version = "1.91" [dependencies] clash-verge-logging = { workspace = true } log = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, features = ["signal"] } [lints] workspace = true diff --git a/crates/clash-verge-signal/src/lib.rs b/crates/clash-verge-signal/src/lib.rs index 46b559d33..60511d960 100644 --- a/crates/clash-verge-signal/src/lib.rs +++ b/crates/clash-verge-signal/src/lib.rs @@ -1,4 +1,7 @@ -use std::sync::OnceLock; +use std::sync::{ + OnceLock, + atomic::{AtomicBool, Ordering}, +}; use clash_verge_logging::{Type, logging}; @@ -8,11 +11,42 @@ mod unix; mod windows; pub(crate) static RUNTIME: OnceLock> = OnceLock::new(); +pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new(); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ShutdownOutcome { + Committed, + Canceled, +} + +pub(crate) struct ShutdownLatch { + is_cleaning_up: AtomicBool, +} + +impl ShutdownLatch { + const fn new() -> Self { + Self { + is_cleaning_up: AtomicBool::new(false), + } + } + + fn try_begin(&self) -> bool { + self.is_cleaning_up + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + fn finish(&self, outcome: ShutdownOutcome) { + if matches!(outcome, ShutdownOutcome::Canceled) { + self.is_cleaning_up.store(false, Ordering::Release); + } + } +} pub fn register(f: F) where F: Fn() -> Fut + Send + Sync + 'static, - Fut: Future + Send + 'static, + Fut: Future + Send + 'static, { RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() { Ok(rt) => Some(rt), @@ -33,3 +67,30 @@ where #[cfg(windows)] windows::register(f); } + +#[cfg(test)] +mod tests { + use super::{ShutdownLatch, ShutdownOutcome}; + + #[test] + fn canceled_shutdown_releases_latch_and_permits_next_attempt() { + let latch = ShutdownLatch::new(); + + assert!(latch.try_begin()); + assert!(!latch.try_begin()); + + latch.finish(ShutdownOutcome::Canceled); + + assert!(latch.try_begin()); + } + + #[test] + fn committed_shutdown_retains_latch_and_suppresses_repeats() { + let latch = ShutdownLatch::new(); + + assert!(latch.try_begin()); + latch.finish(ShutdownOutcome::Committed); + + assert!(!latch.try_begin()); + } +} diff --git a/crates/clash-verge-signal/src/unix.rs b/crates/clash-verge-signal/src/unix.rs index dfb361936..1bba1e833 100644 --- a/crates/clash-verge-signal/src/unix.rs +++ b/crates/clash-verge-signal/src/unix.rs @@ -1,16 +1,12 @@ -use std::sync::atomic::{AtomicBool, Ordering}; - use clash_verge_logging::{Type, logging}; use tokio::signal::unix::{SignalKind, signal}; -use crate::RUNTIME; - -static IS_CLEANING_UP: AtomicBool = AtomicBool::new(false); +use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome}; pub fn register(f: F) where F: Fn() -> Fut + Send + Sync + 'static, - Fut: Future + Send + 'static, + Fut: Future + Send + 'static, { if let Some(Some(rt)) = RUNTIME.get() { rt.spawn(async move { @@ -53,7 +49,7 @@ where } } - if IS_CLEANING_UP.load(Ordering::SeqCst) { + if !SHUTDOWN_LATCH.try_begin() { logging!( info, Type::SystemSignal, @@ -62,11 +58,9 @@ where ); continue; } - IS_CLEANING_UP.store(true, Ordering::SeqCst); - logging!(info, Type::SystemSignal, "Caught signal {}", signal_name); - f().await; + SHUTDOWN_LATCH.finish(f().await); } }); } else { diff --git a/crates/clash-verge-signal/src/windows.rs b/crates/clash-verge-signal/src/windows.rs index d4f80400c..836a59c35 100644 --- a/crates/clash-verge-signal/src/windows.rs +++ b/crates/clash-verge-signal/src/windows.rs @@ -1,16 +1,12 @@ -use std::sync::atomic::{AtomicBool, Ordering}; - use clash_verge_logging::{Type, logging}; use tokio::signal::windows; -use crate::RUNTIME; - -static IS_CLEANING_UP: AtomicBool = AtomicBool::new(false); +use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome}; pub fn register(f: F) where F: Fn() -> Fut + Send + Sync + 'static, - Fut: Future + Send + 'static, + Fut: Future + Send + 'static, { if let Some(Some(rt)) = RUNTIME.get() { rt.spawn(async move { @@ -63,7 +59,7 @@ where } } - if IS_CLEANING_UP.load(Ordering::SeqCst) { + if !SHUTDOWN_LATCH.try_begin() { logging!( info, Type::SystemSignal, @@ -72,11 +68,9 @@ where ); continue; } - IS_CLEANING_UP.store(true, Ordering::SeqCst); - logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name); - f().await; + SHUTDOWN_LATCH.finish(f().await); } }); } else { diff --git a/package.json b/package.json index adf48d44c..11a4da70b 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,11 @@ "license": "GPL-3.0-only", "scripts": { "prepare": "husky || true", - "dev": "cross-env RUST_BACKTRACE=full tauri dev -f verge-dev", - "dev:diff": "cross-env RUST_BACKTRACE=full tauri dev -f verge-dev", - "dev:trace": "cross-env RUST_BACKTRACE=full RUSTFLAGS=\"--cfg tokio_unstable\" tauri dev -f verge-dev tokio-trace", + "dev": "node scripts/dev.mjs dev", + "dev:service": "node scripts/dev.mjs service", + "dev:sidecar": "node scripts/dev.mjs sidecar", + "dev:diff": "node scripts/dev.mjs dev", + "dev:trace": "node scripts/dev.mjs trace", "dev:tauri": "cross-env RUST_BACKTRACE=full tauri dev -f tauri-dev", "build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build", "build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release", @@ -32,6 +34,7 @@ "i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply", "i18n:types": "node scripts/generate-i18n-keys.mjs", "typecheck": "tsc --noEmit", + "test:dev-control": "node --test scripts/dev-control.test.mjs scripts/service-release.test.mjs", "knip:check": "knip", "knip:report": "knip --no-exit-code" }, @@ -51,7 +54,6 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-http": "~2.5.9", - "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-shell": "2.3.5", "@tauri-apps/plugin-updater": "2.10.1", "ahooks": "^3.9.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15a2f934a..6e9716e59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,9 +53,6 @@ importers: '@tauri-apps/plugin-http': specifier: ~2.5.9 version: 2.5.9 - '@tauri-apps/plugin-process': - specifier: ^2.3.1 - version: 2.3.1 '@tauri-apps/plugin-shell': specifier: 2.3.5 version: 2.3.5 @@ -1823,9 +1820,6 @@ packages: '@tauri-apps/plugin-http@2.5.9': resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==} - '@tauri-apps/plugin-process@2.3.1': - resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} - '@tauri-apps/plugin-shell@2.3.5': resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} @@ -5519,10 +5513,6 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 - '@tauri-apps/plugin-process@2.3.1': - dependencies: - '@tauri-apps/api': 2.11.1 - '@tauri-apps/plugin-shell@2.3.5': dependencies: '@tauri-apps/api': 2.11.1 diff --git a/scripts/dev-control.mjs b/scripts/dev-control.mjs new file mode 100644 index 000000000..3b9d0e3a7 --- /dev/null +++ b/scripts/dev-control.mjs @@ -0,0 +1,280 @@ +import { constants } from 'node:fs' +import { open } from 'node:fs/promises' +import { request } from 'node:http' +import { connect } from 'node:net' + +const DEFAULT_TIMEOUT_MS = 10_000 +const DEFAULT_RETRY_DELAY_MS = 100 +const TRANSIENT_REQUEST_ERRORS = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'EPIPE', + 'ETIMEDOUT', +]) +const SAFE_REQUEST_ERROR_CODES = new Set([ + ...TRANSIENT_REQUEST_ERRORS, + 'EACCES', + 'EINVAL', + 'ENOTFOUND', +]) + +function validateInstanceRecord(record) { + if ( + !record || + !Number.isInteger(record.port) || + record.port < 1 || + record.port > 65_535 + ) { + throw new Error('invalid instance record port') + } + if ( + typeof record.token !== 'string' || + !/^[0-9a-f]{64}$/.test(record.token) + ) { + throw new Error('invalid instance record token') + } + return { port: record.port, token: record.token } +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +function safeRequestError(error) { + const code = SAFE_REQUEST_ERROR_CODES.has(error?.code) + ? error.code + : 'UNKNOWN' + const cause = new Error(code) + cause.code = code + return new Error('dev quit request failed', { cause }) +} + +export async function readPrivateInstanceRecord(path, expectedUid) { + let handle + try { + handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) + const stat = await handle.stat() + if (!stat.isFile()) { + throw new Error('instance record must be an ordinary file') + } + if (stat.uid !== expectedUid) { + throw new Error('instance record has the wrong owner') + } + if ((stat.mode & 0o777) !== 0o600) { + throw new Error('instance record permissions must be 0600') + } + const contents = await handle.readFile({ encoding: 'utf8' }) + let record + try { + record = JSON.parse(contents) + } catch { + throw new Error('instance record is not valid JSON') + } + return validateInstanceRecord(record) + } finally { + await handle?.close() + } +} + +function postDevQuit(record, timeoutMs, requestImpl) { + return new Promise((resolve, reject) => { + let settled = false + const settle = (callback, value) => { + if (settled) return + settled = true + callback(value) + } + const outgoing = requestImpl( + { + host: '127.0.0.1', + port: record.port, + path: '/commands/dev/quit', + method: 'POST', + agent: false, + headers: { + 'X-Instance-Token': record.token, + }, + }, + (response) => { + response.on('aborted', () => {}) + response.on('error', () => {}) + response.on('close', () => {}) + settle(resolve, response.statusCode) + response.destroy() + }, + ) + outgoing.once('error', (error) => settle(reject, error)) + outgoing.setTimeout(timeoutMs, () => { + const error = new Error('dev quit request timed out') + error.code = 'ETIMEDOUT' + outgoing.destroy(error) + }) + outgoing.end() + }) +} + +export async function requestDevQuit( + recordOrReader, + { + timeoutMs = DEFAULT_TIMEOUT_MS, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, + requestImpl = request, + now = Date.now, + sleep = delay, + } = {}, +) { + const deadline = now() + timeoutMs + const readRecord = + typeof recordOrReader === 'function' + ? recordOrReader + : async () => recordOrReader + + while (now() < deadline) { + let record + try { + record = validateInstanceRecord(await readRecord()) + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error + } + await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now()))) + continue + } + + const remainingMs = Math.max(1, deadline - now()) + let status + try { + status = await postDevQuit(record, remainingMs, requestImpl) + } catch (error) { + if (!TRANSIENT_REQUEST_ERRORS.has(error?.code)) { + throw safeRequestError(error) + } + await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now()))) + continue + } + if (status === 202) { + return record + } + if (status !== 503) { + throw new Error( + `dev quit request returned HTTP ${status ?? 'without a status'}`, + ) + } + await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now()))) + } + + throw new Error('timed out waiting for the dev quit request to be accepted') +} + +function portAcceptsConnections(port, timeoutMs) { + return new Promise((resolve) => { + const socket = connect({ host: '127.0.0.1', port }) + let settled = false + const finish = (isOpen) => { + if (settled) return + settled = true + socket.destroy() + resolve(isOpen) + } + socket.once('connect', () => finish(true)) + socket.once('error', () => finish(false)) + socket.setTimeout(Math.max(1, Math.min(1_000, timeoutMs)), () => + finish(false), + ) + }) +} + +export async function waitForPortClosed( + port, + { + timeoutMs = DEFAULT_TIMEOUT_MS, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, + probePort = portAcceptsConnections, + now = Date.now, + sleep = delay, + } = {}, +) { + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error('invalid loopback port') + } + const deadline = now() + timeoutMs + while (now() < deadline) { + const remainingMs = deadline - now() + if (!(await probePort(port, remainingMs))) { + return + } + await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now()))) + } + throw new Error('timed out waiting for the dev endpoint to close') +} + +export function createMacosShutdownController({ + requestQuit, + waitForPortClosed: waitForClosed, + waitForChildExit, + terminateGroup, + finalizeGroup = terminateGroup, + exit, + report = (message) => console.error(message), + timeoutMs = DEFAULT_TIMEOUT_MS, + forceTimeoutMs = 2_000, + now = Date.now, +}) { + let signalCount = 0 + let forced = false + let forcedPromise + let groupFinalizationPromise + + const remainingBudget = (deadline) => { + const remaining = deadline - now() + if (remaining <= 0) { + throw new Error('timed out waiting for graceful dev shutdown') + } + return remaining + } + + const finalizeOnce = (operation, budget) => { + groupFinalizationPromise ??= Promise.resolve().then(() => operation(budget)) + return groupFinalizationPromise + } + + const gracefulShutdown = async () => { + const deadline = now() + timeoutMs + try { + const record = await requestQuit(remainingBudget(deadline)) + if (forced) return false + await waitForClosed(record.port, remainingBudget(deadline)) + if (forced) return false + await waitForChildExit(remainingBudget(deadline)) + if (forced) return false + await finalizeOnce(finalizeGroup, remainingBudget(deadline)) + if (forced) return false + exit(130) + return true + } catch (error) { + report( + `Graceful dev shutdown failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return false + } + } + + return { + handleSigint() { + signalCount += 1 + if (signalCount === 1) { + return gracefulShutdown() + } + if (!forcedPromise) { + forced = true + forcedPromise = finalizeOnce(terminateGroup, forceTimeoutMs).then( + () => { + exit(130) + return true + }, + ) + } + return forcedPromise + }, + } +} diff --git a/scripts/dev-control.test.mjs b/scripts/dev-control.test.mjs new file mode 100644 index 000000000..0626ddbc6 --- /dev/null +++ b/scripts/dev-control.test.mjs @@ -0,0 +1,1167 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { chmod, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { inspect } from 'node:util' + +import { + createMacosShutdownController, + readPrivateInstanceRecord, + requestDevQuit, + waitForPortClosed, +} from './dev-control.mjs' +import { + buildTauriInvocation, + mirrorChildExit, + prepareDevRuntime, + runDevAnchor, + runDevCommand, + terminateDetachedGroup, +} from './dev.mjs' +import { windowsElevationInvocation } from './dev-service.mjs' + +const token = 'ab'.repeat(32) +const posixOnly = { + skip: process.platform === 'win32' ? 'requires POSIX file metadata' : false, +} + +test('development startup preserves the installed service state unless service mode is explicit', async () => { + for (const mode of ['dev', 'trace', 'sidecar', 'service']) { + const calls = [] + const result = await prepareDevRuntime(mode, { + ensureService: async () => { + calls.push('ensure') + return '/service' + }, + prepareService: async () => { + calls.push('prepare') + return '/service' + }, + }) + + assert.deepEqual(calls, [mode === 'service' ? 'ensure' : 'prepare']) + assert.equal(result.mode, mode === 'service' ? 'dev' : mode) + assert.equal(result.serviceDirectory, '/service') + } +}) + +test('Windows elevation passes the installer path outside PowerShell source', () => { + const installer = String.raw`C:\ssp\path with spaces\clash-verge-service-install.exe` + const baseEnvironment = { SYSTEMROOT: String.raw`C:\Windows` } + + const invocation = windowsElevationInvocation(installer, baseEnvironment) + + assert.equal(invocation.command, 'powershell.exe') + assert.equal(invocation.args.at(-2), '-Command') + assert.equal(invocation.args.includes(installer), false) + assert.equal(invocation.env.CLASH_VERGE_DEV_SERVICE_INSTALLER, installer) + assert.equal(baseEnvironment.CLASH_VERGE_DEV_SERVICE_INSTALLER, undefined) +}) + +async function withTemporaryDirectory(run) { + const root = await mkdtemp(join(tmpdir(), 'clash-verge-dev-control-')) + try { + await run(root) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +function requestSequence(statuses, requests) { + return (options, onResponse) => { + const outgoing = new EventEmitter() + outgoing.setTimeout = () => outgoing + outgoing.destroy = (error) => + queueMicrotask(() => outgoing.emit('error', error)) + outgoing.end = () => { + requests.push(options) + const outcome = statuses.shift() + if (outcome instanceof Error) { + queueMicrotask(() => outgoing.emit('error', outcome)) + return + } + const response = new EventEmitter() + response.statusCode = outcome + response.resume = () => {} + response.destroy = () => {} + queueMicrotask(() => { + onResponse(response) + queueMicrotask(() => response.emit('end')) + }) + } + return outgoing + } +} + +function responseLifecycleRequest(status, events) { + return (_options, onResponse) => { + const outgoing = new EventEmitter() + outgoing.setTimeout = () => outgoing + outgoing.destroy = (error) => + queueMicrotask(() => outgoing.emit('error', error)) + outgoing.end = () => { + const response = new EventEmitter() + response.statusCode = status + response.resume = () => {} + response.destroy = () => {} + queueMicrotask(() => { + onResponse(response) + for (const event of events) { + response.emit( + event, + event === 'error' ? new Error('response failed') : undefined, + ) + } + }) + } + return outgoing + } +} + +function releasedResponseSequence(statuses, releases) { + return (_options, onResponse) => { + const outgoing = new EventEmitter() + outgoing.setTimeout = () => outgoing + outgoing.destroy = (error) => + queueMicrotask(() => outgoing.emit('error', error)) + outgoing.end = () => { + const response = new EventEmitter() + response.statusCode = statuses.shift() + response.resume = () => {} + response.destroy = () => { + releases.push(response.statusCode) + response.emit('aborted') + response.emit('error', new Error('destroyed response')) + response.emit('close') + } + queueMicrotask(() => onResponse(response)) + } + return outgoing + } +} + +function createChild(pid) { + const child = new EventEmitter() + child.pid = pid + return child +} + +function flushTasks() { + return new Promise((resolve) => setImmediate(resolve)) +} + +test( + 'private instance record must be an owned 0600 ordinary file', + posixOnly, + async () => { + await withTemporaryDirectory(async (root) => { + const recordPath = join(root, 'singleton-instance.json') + await writeFile(recordPath, JSON.stringify({ port: 42123, token }), { + mode: 0o600, + }) + assert.deepEqual( + await readPrivateInstanceRecord(recordPath, process.getuid()), + { + port: 42123, + token, + }, + ) + + await chmod(recordPath, 0o644) + await assert.rejects( + readPrivateInstanceRecord(recordPath, process.getuid()), + /0600/, + ) + }) + }, +) + +test('private instance record rejects a symbolic link', posixOnly, async () => { + await withTemporaryDirectory(async (root) => { + const targetPath = join(root, 'target.json') + const recordPath = join(root, 'singleton-instance.json') + await writeFile(targetPath, JSON.stringify({ port: 42123, token }), { + mode: 0o600, + }) + await symlink(targetPath, recordPath) + + await assert.rejects( + readPrivateInstanceRecord(recordPath, process.getuid()), + ) + }) +}) + +test('private instance record rejects the wrong owner', posixOnly, async () => { + await withTemporaryDirectory(async (root) => { + const recordPath = join(root, 'singleton-instance.json') + await writeFile(recordPath, JSON.stringify({ port: 42123, token }), { + mode: 0o600, + }) + + await assert.rejects( + readPrivateInstanceRecord(recordPath, process.getuid() + 1), + /owner/, + ) + }) +}) + +test('private instance record rejects invalid ports', posixOnly, async (t) => { + for (const port of [0, 65536, 1.5, '42123']) { + await t.test(String(port), async () => { + await withTemporaryDirectory(async (root) => { + const recordPath = join(root, 'singleton-instance.json') + await writeFile(recordPath, JSON.stringify({ port, token }), { + mode: 0o600, + }) + await assert.rejects( + readPrivateInstanceRecord(recordPath, process.getuid()), + /port/, + ) + }) + }) + } +}) + +test( + 'private instance record rejects tokens outside the lowercase 64-character format', + posixOnly, + async (t) => { + for (const invalidToken of [ + 'ab'.repeat(31), + 'AB'.repeat(32), + 'z'.repeat(64), + ]) { + await t.test( + invalidToken.length === 64 ? invalidToken.slice(0, 2) : 'short', + async () => { + await withTemporaryDirectory(async (root) => { + const recordPath = join(root, 'singleton-instance.json') + await writeFile( + recordPath, + JSON.stringify({ port: 42123, token: invalidToken }), + { mode: 0o600 }, + ) + await assert.rejects( + readPrivateInstanceRecord(recordPath, process.getuid()), + /token/, + ) + }) + }, + ) + } + }, +) + +test('requestDevQuit posts only to loopback and retries 503', async () => { + const requests = [] + const record = { port: 42123, token } + assert.deepEqual( + await requestDevQuit(record, { + requestImpl: requestSequence([503, 202], requests), + retryDelayMs: 1, + timeoutMs: 1_000, + }), + record, + ) + assert.equal(requests.length, 2) + for (const options of requests) { + assert.equal(options.host, '127.0.0.1') + assert.equal(options.port, 42123) + assert.equal(options.method, 'POST') + assert.equal(options.path, '/commands/dev/quit') + assert.equal(options.headers['X-Instance-Token'], token) + } +}) + +test('requestDevQuit retries a missing record source and returns the accepted record', async () => { + let reads = 0 + const record = await requestDevQuit( + async () => { + reads += 1 + if (reads === 1) { + const error = new Error('missing') + error.code = 'ENOENT' + throw error + } + return { port: 42123, token } + }, + { + requestImpl: requestSequence([202], []), + retryDelayMs: 1, + timeoutMs: 1_000, + }, + ) + assert.deepEqual(record, { port: 42123, token }) + assert.equal(reads, 2) +}) + +test('requestDevQuit errors never disclose the instance token', async () => { + await assert.rejects( + requestDevQuit( + { port: 42123, token }, + { requestImpl: requestSequence([500], []) }, + ), + (error) => { + assert.equal(error.message.includes(token), false) + assert.match(error.message, /500/) + return true + }, + ) +}) + +test('non-transient request errors preserve only a token-safe error cause', async () => { + const unsafe = new Error(`permission denied for token ${token}`) + unsafe.code = 'EACCES' + unsafe.options = { headers: { 'X-Instance-Token': token } } + let resultError + + await assert.rejects( + requestDevQuit( + { port: 42123, token }, + { requestImpl: requestSequence([unsafe], []) }, + ), + (error) => { + resultError = error + return true + }, + ) + + const rendered = inspect(resultError, { depth: null }) + assert.equal(rendered.includes(token), false) + assert.match(resultError.cause.message, /EACCES/) + assert.notEqual(resultError.cause, unsafe) +}) + +test('requestDevQuit settles from response headers and consumes shutdown lifecycle events', async () => { + const record = { port: 42123, token } + for (const events of [ + ['aborted', 'close'], + ['error', 'close'], + ]) { + assert.deepEqual( + await requestDevQuit(record, { + requestImpl: responseLifecycleRequest(202, events), + }), + record, + ) + } +}) + +test('requestDevQuit actively releases 503 and 202 responses after headers', async () => { + const releases = [] + assert.deepEqual( + await requestDevQuit( + { port: 42123, token }, + { + requestImpl: releasedResponseSequence([503, 202], releases), + retryDelayMs: 1, + timeoutMs: 100, + }, + ), + { port: 42123, token }, + ) + assert.deepEqual(releases, [503, 202]) +}) + +test('requestDevQuit retries transient connection errors without disclosing the token', async () => { + const transient = new Error(`connect failed with secret ${token}`) + transient.code = 'ECONNREFUSED' + const requests = [] + assert.deepEqual( + await requestDevQuit( + { port: 42123, token }, + { + requestImpl: requestSequence([transient, 202], requests), + retryDelayMs: 1, + timeoutMs: 100, + }, + ), + { port: 42123, token }, + ) + assert.equal(requests.length, 2) +}) + +test('requestDevQuit bounds ENOENT, 503, and transient-error retries', async (t) => { + const cases = [ + { + name: 'ENOENT', + source: async () => { + const error = new Error('missing') + error.code = 'ENOENT' + throw error + }, + requestImpl: requestSequence([], []), + }, + { + name: '503', + source: { port: 42123, token }, + requestImpl: requestSequence(Array(20).fill(503), []), + }, + { + name: 'transient error', + source: { port: 42123, token }, + requestImpl: requestSequence( + Array.from({ length: 20 }, () => { + const error = new Error(`reset ${token}`) + error.code = 'ECONNRESET' + return error + }), + [], + ), + }, + ] + + for (const entry of cases) { + await t.test(entry.name, async () => { + await assert.rejects( + requestDevQuit(entry.source, { + requestImpl: entry.requestImpl, + retryDelayMs: 1, + timeoutMs: 5, + }), + (error) => { + assert.match(error.message, /timed out/) + assert.equal(error.message.includes(token), false) + return true + }, + ) + }) + } +}) + +test('waitForPortClosed resolves only after the loopback connection probe fails', async () => { + let probes = 0 + await waitForPortClosed(42123, { + probePort: async () => { + probes += 1 + return probes < 3 + }, + retryDelayMs: 1, + timeoutMs: 1_000, + }) + assert.equal(probes, 3) +}) + +test('waitForPortClosed gives each probe only the remaining absolute budget', async () => { + let now = 1_000 + const budgets = [] + await assert.rejects( + waitForPortClosed(42123, { + now: () => now, + probePort: async (_port, budget) => { + budgets.push(budget) + return true + }, + retryDelayMs: 3, + sleep: async (milliseconds) => { + now += milliseconds + }, + timeoutMs: 10, + }), + /timed out/, + ) + assert.deepEqual(budgets, [10, 7, 4, 1]) +}) + +test('first SIGINT finalizes the group before exiting 130', async () => { + const calls = [] + const controller = createMacosShutdownController({ + requestQuit: async () => { + calls.push('request') + return { port: 42123 } + }, + waitForPortClosed: async () => { + calls.push('closed') + }, + waitForChildExit: async () => { + calls.push('child') + }, + terminateGroup: () => calls.push('terminate'), + exit: (code) => calls.push(`exit:${code}`), + }) + + await controller.handleSigint() + assert.deepEqual(calls, [ + 'request', + 'closed', + 'child', + 'terminate', + 'exit:130', + ]) +}) + +test('first timeout preserves the group and second SIGINT forces it exactly once', async () => { + const calls = [] + const controller = createMacosShutdownController({ + requestQuit: async () => { + throw new Error('timed out') + }, + waitForPortClosed: async () => {}, + waitForChildExit: async () => {}, + terminateGroup: () => calls.push('terminate'), + exit: (code) => calls.push(`exit:${code}`), + report: (message) => calls.push(message), + }) + + await controller.handleSigint() + assert.equal(calls.includes('terminate'), false) + assert.equal( + calls.some((call) => String(call).includes('timed out')), + true, + ) + await controller.handleSigint() + await controller.handleSigint() + assert.equal(calls.filter((call) => call === 'terminate').length, 1) +}) + +test('second SIGINT terminates the group while the first request is still pending', async () => { + let releaseRequest + const requestPending = new Promise((resolve) => { + releaseRequest = resolve + }) + const calls = [] + const controller = createMacosShutdownController({ + requestQuit: async () => requestPending, + waitForPortClosed: async () => {}, + waitForChildExit: async () => {}, + terminateGroup: () => calls.push('terminate'), + exit: (code) => calls.push(`exit:${code}`), + }) + + const graceful = controller.handleSigint() + await controller.handleSigint() + assert.deepEqual(calls, ['terminate', 'exit:130']) + releaseRequest({ port: 42123 }) + await graceful +}) + +test('third SIGINT shares the in-progress forced termination', async () => { + let finishTermination + const terminating = new Promise((resolve) => { + finishTermination = resolve + }) + const calls = [] + const controller = createMacosShutdownController({ + requestQuit: async () => new Promise(() => {}), + waitForPortClosed: async () => {}, + waitForChildExit: async () => {}, + terminateGroup: async () => { + calls.push('terminate') + await terminating + }, + exit: (code) => calls.push(`exit:${code}`), + }) + + void controller.handleSigint() + const forced = controller.handleSigint() + const repeated = controller.handleSigint() + await flushTasks() + assert.deepEqual(calls, ['terminate']) + finishTermination() + assert.equal(await forced, true) + assert.equal(await repeated, true) + assert.deepEqual(calls, ['terminate', 'exit:130']) +}) + +test('trace invocation preserves the existing Rust flags and Tauri arguments', () => { + const invocation = buildTauriInvocation('trace', { PATH: '/bin' }, 'darwin') + assert.deepEqual(invocation.args, [ + 'exec', + 'tauri', + 'dev', + '-f', + 'verge-dev', + 'tokio-trace', + ]) + assert.equal(invocation.env.RUST_BACKTRACE, 'full') + assert.equal(invocation.env.RUSTFLAGS, '--cfg tokio_unstable') + assert.equal(invocation.detached, true) +}) + +test('normal dev invocation preserves arguments and remains direct off macOS', () => { + const invocation = buildTauriInvocation('dev', { PATH: '/bin' }, 'linux') + assert.equal(invocation.command, 'pnpm') + assert.deepEqual(invocation.args, ['exec', 'tauri', 'dev', '-f', 'verge-dev']) + assert.deepEqual(invocation.env, { PATH: '/bin', RUST_BACKTRACE: 'full' }) + assert.equal(invocation.detached, false) +}) + +test('sidecar invocation explicitly enables only the development sidecar feature', () => { + const invocation = buildTauriInvocation('sidecar', { PATH: '/bin' }, 'win32') + assert.deepEqual(invocation.args, [ + 'exec', + 'tauri', + 'dev', + '-f', + 'verge-dev,dev-sidecar', + ]) + assert.equal(invocation.detached, false) +}) + +test('macOS parent launches a detached Node anchor with inherited stdio and IPC', async () => { + const anchor = createChild(4321) + let spawned + const running = runDevCommand('trace', { + environment: { HOME: '/tmp/home', PATH: '/bin' }, + installSigint: () => () => {}, + platform: 'darwin', + spawnChild: (command, args, options) => { + spawned = { command, args, options } + queueMicrotask(() => { + anchor.emit('spawn') + anchor.emit('message', { type: 'ready' }) + }) + return anchor + }, + }) + await running + + assert.equal(spawned.command, process.execPath) + assert.deepEqual(spawned.args.slice(-2), ['--anchor', 'trace']) + assert.equal(spawned.options.detached, true) + assert.deepEqual(spawned.options.stdio, [ + 'inherit', + 'inherit', + 'inherit', + 'ipc', + ]) +}) + +test('anchor launches the exact inner command non-detached and stays for release', async () => { + const inner = createChild(8765) + const sent = [] + const exits = [] + let handleMessage + let handleSigterm + let spawned + const starting = runDevAnchor('trace', { + environment: { PATH: '/bin' }, + exit: (code) => exits.push(code), + installDisconnect: () => () => {}, + installMessage: (handler) => { + handleMessage = handler + return () => {} + }, + installSigterm: (handler) => { + handleSigterm = handler + return () => {} + }, + send: (message) => sent.push(message), + spawnChild: (command, args, options) => { + spawned = { command, args, options } + queueMicrotask(() => inner.emit('spawn')) + return inner + }, + }) + await starting + + assert.equal(spawned.command, 'pnpm') + assert.deepEqual(spawned.args, [ + 'exec', + 'tauri', + 'dev', + '-f', + 'verge-dev', + 'tokio-trace', + ]) + assert.equal(spawned.options.detached, false) + assert.equal(spawned.options.stdio, 'inherit') + assert.deepEqual(sent, [{ type: 'ready' }]) + + handleSigterm() + assert.deepEqual(exits, []) + inner.emit('exit', 0, null) + await flushTasks() + assert.deepEqual(sent.at(-1), { + type: 'inner-result', + code: 0, + signal: null, + }) + assert.deepEqual(exits, []) + + handleMessage({ type: 'release' }) + assert.deepEqual(exits, [0]) +}) + +test('anchor cleans its own group after parent IPC disconnect', async () => { + const inner = createChild(8765) + const calls = [] + let handleDisconnect + const starting = runDevAnchor('dev', { + environment: { PATH: '/bin' }, + exit: (code) => calls.push(`exit:${code}`), + installDisconnect: (handler) => { + handleDisconnect = handler + return () => {} + }, + installMessage: () => () => {}, + installSigterm: () => () => {}, + ownPid: 4321, + send: () => {}, + spawnChild: () => { + queueMicrotask(() => inner.emit('spawn')) + return inner + }, + terminateGroup: async (pid) => calls.push(`terminate:${pid}`), + }) + await starting + + handleDisconnect() + await flushTasks() + assert.deepEqual(calls, ['terminate:4321', 'exit:1']) +}) + +test('first finalize and second SIGINT share one group finalization', async () => { + let finishFinalize + const finalizing = new Promise((resolve) => { + finishFinalize = resolve + }) + const calls = [] + const controller = createMacosShutdownController({ + exit: (code) => calls.push(`exit:${code}`), + finalizeGroup: async () => { + calls.push('finalize') + await finalizing + }, + requestQuit: async () => ({ port: 42123 }), + terminateGroup: async () => calls.push('force'), + waitForChildExit: async () => {}, + waitForPortClosed: async () => {}, + }) + + const graceful = controller.handleSigint() + await flushTasks() + const forced = controller.handleSigint() + const repeated = controller.handleSigint() + assert.deepEqual(calls, ['finalize']) + finishFinalize() + await Promise.all([graceful, forced, repeated]) + assert.equal(calls.filter((call) => call === 'finalize').length, 1) + assert.equal(calls.includes('force'), false) + assert.deepEqual( + calls.filter((call) => call.startsWith('exit:')), + ['exit:130'], + ) +}) + +test('graceful shutdown shares one absolute ten-second budget', async () => { + let now = 1_000 + const budgets = [] + const controller = createMacosShutdownController({ + exit: () => {}, + finalizeGroup: async (budget) => { + budgets.push(['finalize', budget]) + now += 500 + }, + now: () => now, + requestQuit: async (budget) => { + budgets.push(['request', budget]) + now += 3_000 + return { port: 42123 } + }, + terminateGroup: async () => {}, + timeoutMs: 10_000, + waitForChildExit: async (budget) => { + budgets.push(['inner', budget]) + now += 4_000 + }, + waitForPortClosed: async (_port, budget) => { + budgets.push(['endpoint', budget]) + now += 2_000 + }, + }) + + await controller.handleSigint() + assert.deepEqual(budgets, [ + ['request', 10_000], + ['endpoint', 7_000], + ['inner', 5_000], + ['finalize', 1_000], + ]) +}) + +test('macOS validates HOME before spawning', async () => { + let spawned = false + await assert.rejects( + runDevCommand('dev', { + environment: { PATH: '/bin' }, + platform: 'darwin', + spawnChild: () => { + spawned = true + }, + }), + /HOME/, + ) + assert.equal(spawned, false) +}) + +test('macOS rejects spawn failure and unavailable PID without group signals', async (t) => { + for (const scenario of ['error', 'missing pid']) { + await t.test(scenario, async () => { + const child = createChild(undefined) + const groupSignals = [] + const start = runDevCommand('dev', { + environment: { HOME: '/tmp/home', PATH: '/bin' }, + installSigint: () => () => {}, + platform: 'darwin', + signalGroup: (...args) => groupSignals.push(args), + spawnChild: () => { + queueMicrotask(() => { + if (scenario === 'error') + child.emit('error', new Error('spawn failed')) + else child.emit('spawn') + }) + return child + }, + }) + await assert.rejects(start, scenario === 'error' ? /spawn failed/ : /PID/) + assert.deepEqual(groupSignals, []) + }) + } +}) + +test('SIGINT received during macOS spawn setup waits for a validated child PID', async () => { + const child = createChild(4321) + let handleSigint + const calls = [] + const starting = runDevCommand('dev', { + environment: { HOME: '/tmp/home', PATH: '/bin' }, + exit: (code) => calls.push(`exit:${code}`), + installSigint: (handler) => { + handleSigint = handler + return () => {} + }, + platform: 'darwin', + requestQuit: async () => new Promise(() => {}), + spawnChild: () => child, + terminateGroup: async (pid) => calls.push(`terminate:${pid}`), + }) + await flushTasks() + handleSigint() + handleSigint() + assert.deepEqual(calls, []) + child.emit('spawn') + child.emit('message', { type: 'ready' }) + await starting + await flushTasks() + assert.deepEqual(calls, ['terminate:4321', 'exit:130']) +}) + +test('inner leader exit leaves the anchor available for lingering forced group cleanup', async () => { + const child = createChild(4321) + let handleSigint + let finishTermination + const terminating = new Promise((resolve) => { + finishTermination = resolve + }) + const calls = [] + const started = runDevCommand('dev', { + environment: { HOME: '/tmp/home', PATH: '/bin' }, + exit: (code) => calls.push(`exit:${code}`), + installSigint: (handler) => { + handleSigint = handler + return () => {} + }, + mirrorResult: (result) => + calls.push(`mirror:${result.code}:${result.signal}`), + platform: 'darwin', + report: (message) => calls.push(message), + requestQuit: async () => { + throw new Error('timed out') + }, + spawnChild: () => { + queueMicrotask(() => { + child.emit('spawn') + child.emit('message', { type: 'ready' }) + }) + return child + }, + terminateGroup: async () => { + calls.push('terminate') + await terminating + }, + }) + await started + + handleSigint() + await flushTasks() + child.emit('message', { + type: 'inner-result', + code: 0, + signal: null, + }) + await flushTasks() + assert.equal( + calls.some((call) => call.startsWith('mirror:')), + false, + ) + handleSigint() + handleSigint() + await flushTasks() + assert.equal(calls.filter((call) => call === 'terminate').length, 1) + assert.equal(calls.includes('exit:130'), false) + finishTermination() + await flushTasks() + assert.equal(calls.filter((call) => call === 'exit:130').length, 1) +}) + +test('unexpected anchor exit makes delayed force cleanup fail closed', async () => { + const anchor = createChild(4321) + let handleSigint + const calls = [] + await runDevCommand('dev', { + environment: { HOME: '/tmp/home', PATH: '/bin' }, + exit: (code) => calls.push(`exit:${code}`), + installSigint: (handler) => { + handleSigint = handler + return () => {} + }, + platform: 'darwin', + report: (message) => calls.push(`report:${message}`), + requestQuit: async () => { + throw new Error('timed out') + }, + signalGroup: (pid, signal) => calls.push(`signal:${pid}:${signal}`), + spawnChild: () => { + queueMicrotask(() => { + anchor.emit('spawn') + anchor.emit('message', { type: 'ready' }) + }) + return anchor + }, + }) + + handleSigint() + await flushTasks() + anchor.emit('exit', 1, null) + await flushTasks() + handleSigint() + await flushTasks() + + assert.equal( + calls.some((call) => call.startsWith('signal:')), + false, + ) + assert.equal( + calls.some((call) => call.includes('unsafe')), + true, + ) + assert.deepEqual( + calls.filter((call) => call.startsWith('exit:')), + ['exit:1'], + ) +}) + +test('normal inner completion releases the anchor before mirroring the result', async () => { + const anchor = createChild(4321) + const calls = [] + anchor.send = (message, callback) => { + calls.push(message.type) + queueMicrotask(() => { + callback?.() + anchor.emit('exit', 0, null) + }) + } + await runDevCommand('dev', { + environment: { HOME: '/tmp/home', PATH: '/bin' }, + installSigint: () => () => {}, + mirrorResult: ({ code, signal }) => calls.push(`mirror:${code}:${signal}`), + platform: 'darwin', + signalGroup: (pid, signal) => { + calls.push(`probe:${pid}:${signal}`) + const error = new Error('group absent') + error.code = 'ESRCH' + throw error + }, + spawnChild: () => { + queueMicrotask(() => { + anchor.emit('spawn') + anchor.emit('message', { type: 'ready' }) + }) + return anchor + }, + }) + + anchor.emit('message', { + type: 'inner-result', + code: 0, + signal: null, + }) + await flushTasks() + await flushTasks() + assert.deepEqual(calls, ['release', 'probe:-4321:0', 'mirror:0:null']) +}) + +test('normal release fails closed while the original group still exists', async () => { + const anchor = createChild(4321) + const calls = [] + anchor.send = (message, callback) => { + calls.push(message.type) + queueMicrotask(() => { + callback?.() + anchor.emit('exit', 0, null) + }) + } + await runDevCommand('dev', { + environment: { HOME: '/tmp/home', PATH: '/bin' }, + exit: (code) => calls.push(`exit:${code}`), + installSigint: () => () => {}, + mirrorResult: () => calls.push('mirror'), + platform: 'darwin', + report: (message) => calls.push(`report:${message}`), + signalGroup: (pid, signal) => calls.push(`probe:${pid}:${signal}`), + spawnChild: () => { + queueMicrotask(() => { + anchor.emit('spawn') + anchor.emit('message', { type: 'ready' }) + }) + return anchor + }, + }) + + anchor.emit('message', { + type: 'inner-result', + code: 0, + signal: null, + }) + await flushTasks() + await flushTasks() + + assert.deepEqual(calls, [ + 'release', + 'probe:-4321:0', + 'report:Dev command failed: dev process group still exists after anchor exit', + 'exit:1', + ]) +}) + +test('failed forced cleanup is shared, reported once, and never mirrors child exit', async () => { + const child = createChild(4321) + let handleSigint + const calls = [] + await runDevCommand('dev', { + environment: { HOME: '/tmp/home', PATH: '/bin' }, + exit: (code) => calls.push(`exit:${code}`), + installSigint: (handler) => { + handleSigint = handler + return () => {} + }, + mirrorResult: () => calls.push('mirror'), + platform: 'darwin', + report: (message) => calls.push(`report:${message}`), + requestQuit: async () => new Promise(() => {}), + spawnChild: () => { + queueMicrotask(() => { + child.emit('spawn') + child.emit('message', { type: 'ready' }) + }) + return child + }, + terminateGroup: async () => { + calls.push('terminate') + throw new Error('forced cleanup failed') + }, + }) + + handleSigint() + handleSigint() + handleSigint() + child.emit('exit', 0, null) + await flushTasks() + await flushTasks() + + assert.equal(calls.filter((call) => call === 'terminate').length, 1) + assert.equal(calls.filter((call) => call.startsWith('report:')).length, 1) + assert.deepEqual( + calls.filter((call) => call.startsWith('exit:')), + ['exit:1'], + ) + assert.equal(calls.includes('mirror'), false) +}) + +test('detached group termination waits after TERM, then treats KILL ESRCH as success', async () => { + let now = 0 + const calls = [] + await terminateDetachedGroup(4321, { + graceMs: 100, + now: () => now, + signalGroup: (pid, signal) => { + calls.push(`${pid}:${signal}`) + if (signal === 'SIGKILL') { + const error = new Error('gone') + error.code = 'ESRCH' + throw error + } + }, + sleep: async (milliseconds) => { + calls.push(`sleep:${milliseconds}`) + now += milliseconds + }, + }) + assert.deepEqual(calls, [ + '-4321:SIGTERM', + 'sleep:50', + '-4321:0', + 'sleep:50', + '-4321:SIGKILL', + ]) +}) + +test('non-macOS wiring mirrors child exit codes and signals without installing SIGINT', async (t) => { + for (const result of [ + { code: 7, signal: null }, + { code: null, signal: 'SIGTERM' }, + ]) { + await t.test( + result.code === null ? result.signal : String(result.code), + async () => { + const child = createChild(4321) + const calls = [] + const running = runDevCommand('dev', { + environment: { PATH: '/bin' }, + installSigint: () => { + throw new Error('must not install SIGINT') + }, + mirrorResult: (childResult) => + mirrorChildExit(childResult, { + exit: (code) => calls.push(`exit:${code}`), + signalSelf: (signal) => calls.push(`signal:${signal}`), + }), + platform: 'linux', + spawnChild: () => { + queueMicrotask(() => child.emit('exit', result.code, result.signal)) + return child + }, + }) + await running + assert.deepEqual(calls, [ + result.code === null ? 'signal:SIGTERM' : 'exit:7', + ]) + }, + ) + } +}) + +test('non-macOS spawn errors consume every lifecycle rejection', async () => { + const child = createChild(undefined) + const unhandled = [] + const captureUnhandled = (error) => unhandled.push(error) + process.prependListener('unhandledRejection', captureUnhandled) + try { + const running = runDevCommand('dev', { + environment: { PATH: '/bin' }, + platform: 'linux', + spawnChild: () => { + queueMicrotask(() => child.emit('error', new Error('spawn failed'))) + return child + }, + }) + await assert.rejects(running, /spawn failed/) + await flushTasks() + assert.deepEqual(unhandled, []) + } finally { + process.removeListener('unhandledRejection', captureUnhandled) + } +}) diff --git a/scripts/dev-service.mjs b/scripts/dev-service.mjs new file mode 100644 index 000000000..3c741c963 --- /dev/null +++ b/scripts/dev-service.mjs @@ -0,0 +1,190 @@ +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { access, mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const developmentServiceDirectoryEnvironment = + 'CLASH_VERGE_DEV_SERVICE_DIR' +const developmentServiceInstallerEnvironment = + 'CLASH_VERGE_DEV_SERVICE_INSTALLER' + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) +const repositoryRoot = resolve(scriptDirectory, '..') +const serviceRepository = resolve( + repositoryRoot, + '..', + 'clash-verge-service-ipc', +) +const serviceManifest = join(serviceRepository, 'Cargo.toml') +const developmentReceipt = join( + repositoryRoot, + 'target', + 'development-service.json', +) + +function executable(name, platform) { + return join( + serviceRepository, + 'target', + 'debug', + platform === 'win32' ? `${name}.exe` : name, + ) +} + +function run(command, args, options = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + env: options.env ?? process.env, + stdio: options.stdio ?? 'inherit', + windowsHide: true, + }) + child.once('error', reject) + child.once('exit', (code, signal) => { + if (code === 0) { + resolvePromise() + return + } + const error = new Error( + `${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`, + ) + error.exitCode = code + reject(error) + }) + }) +} + +async function hashFiles(paths) { + const hash = createHash('sha256') + for (const path of paths) { + hash.update(await readFile(path)) + } + return hash.digest('hex') +} + +async function readInstalledFingerprint() { + try { + const parsed = JSON.parse(await readFile(developmentReceipt, 'utf8')) + return typeof parsed.fingerprint === 'string' ? parsed.fingerprint : null + } catch { + return null + } +} + +async function writeInstalledFingerprint(fingerprint) { + await mkdir(dirname(developmentReceipt), { recursive: true }) + await writeFile( + developmentReceipt, + `${JSON.stringify({ fingerprint }, null, 2)}\n`, + 'utf8', + ) +} + +export function windowsElevationInvocation( + installer, + environment = process.env, +) { + const script = [ + "$ErrorActionPreference = 'Stop'", + `$installer = [Environment]::GetEnvironmentVariable('${developmentServiceInstallerEnvironment}', 'Process')`, + "if ([string]::IsNullOrWhiteSpace($installer)) { throw 'Development service installer path is missing' }", + '$child = Start-Process -FilePath $installer -Verb RunAs -Wait -PassThru -WindowStyle Hidden', + 'exit $child.ExitCode', + ].join('; ') + return { + command: 'powershell.exe', + args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + env: { + ...environment, + [developmentServiceInstallerEnvironment]: installer, + }, + } +} + +async function elevateInstaller(installer, platform) { + if (platform === 'win32') { + const invocation = windowsElevationInvocation(installer) + return run(invocation.command, invocation.args, { env: invocation.env }) + } + + if (platform === 'darwin') { + const script = + 'on run argv\nset toolPath to item 1 of argv\nset groupId to item 2 of argv\ndo shell script "CLASH_VERGE_SERVICE_GID=" & quoted form of groupId & " " & quoted form of toolPath with administrator privileges\nend run' + return run('osascript', ['-e', script, installer, String(process.getgid())]) + } + + if (typeof process.getuid === 'function' && process.getuid() === 0) { + return run(installer, [], { + env: { + ...process.env, + CLASH_VERGE_SERVICE_GID: String(process.getgid()), + }, + }) + } + try { + await run('pkexec', [installer]) + } catch (error) { + if (error.code !== 'ENOENT' && error.exitCode !== 127) throw error + await run('sudo', [installer]) + } +} + +export async function prepareDevelopmentService({ + platform = process.platform, +} = {}) { + await access(serviceManifest) + await run('cargo', [ + 'build', + '--manifest-path', + serviceManifest, + '--features', + 'standalone,client,development-channel', + '--bins', + ]) + + const service = executable('clash-verge-service', platform) + await access(service) + return dirname(service) +} + +export async function ensureDevelopmentService({ + platform = process.platform, +} = {}) { + const serviceDirectory = await prepareDevelopmentService({ platform }) + const service = join( + serviceDirectory, + platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service', + ) + const installer = join( + serviceDirectory, + platform === 'win32' + ? 'clash-verge-service-install.exe' + : 'clash-verge-service-install', + ) + const driver = join( + serviceDirectory, + platform === 'win32' + ? 'service-integration-driver.exe' + : 'service-integration-driver', + ) + const fingerprint = await hashFiles([service, installer]) + const installedFingerprint = await readInstalledFingerprint() + + if (installedFingerprint === fingerprint) { + try { + await run(driver, ['probe'], { stdio: 'ignore' }) + return serviceDirectory + } catch { + // The expected binary is installed but unavailable; the repair tool owns recovery. + } + } + + try { + await elevateInstaller(installer, platform) + } catch (error) { + if (error.exitCode !== 75) throw error + } + await run(driver, ['ready']) + await writeInstalledFingerprint(fingerprint) + return serviceDirectory +} diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 000000000..e65c98cd6 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,565 @@ +import { spawn } from 'node:child_process' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { + createMacosShutdownController, + readPrivateInstanceRecord, + requestDevQuit, + waitForPortClosed, +} from './dev-control.mjs' +import { + developmentServiceDirectoryEnvironment, + ensureDevelopmentService, + prepareDevelopmentService, +} from './dev-service.mjs' + +const FORCE_GRACE_MS = 2_000 +const SHUTDOWN_TIMEOUT_MS = 10_000 +const FORCE_POLL_MS = 50 +const ANCHOR_EXIT_RESERVE_MS = 100 + +export function buildTauriInvocation( + mode, + environment = process.env, + platform = process.platform, +) { + const env = { ...environment, RUST_BACKTRACE: 'full' } + const features = mode === 'sidecar' ? 'verge-dev,dev-sidecar' : 'verge-dev' + const args = ['exec', 'tauri', 'dev', '-f', features] + if (mode === 'trace') { + env.RUSTFLAGS = '--cfg tokio_unstable' + args.push('tokio-trace') + } + return { + command: 'pnpm', + args, + env, + detached: platform === 'darwin', + } +} + +function wait(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +function childLifecycle(child) { + const spawned = new Promise((resolve, reject) => { + child.once('spawn', resolve) + child.once('error', reject) + }) + spawned.catch(() => {}) + const result = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) + }) + result.catch(() => {}) + return { result, spawned } +} + +function waitForPromise(promise, timeoutMs, message) { + if (timeoutMs <= 0) return Promise.reject(new Error(message)) + let timeout + return Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs) + }), + ]).finally(() => clearTimeout(timeout)) +} + +function signalDetachedGroup(childPid, signal, signalGroup) { + if (!Number.isInteger(childPid) || childPid <= 0) { + throw new Error('dev command did not provide a valid child PID') + } + try { + signalGroup(-childPid, signal) + return true + } catch (error) { + if (error?.code === 'ESRCH') return false + throw error + } +} + +export async function terminateDetachedGroup( + childPid, + { + signalGroup = process.kill.bind(process), + sleep = wait, + now = Date.now, + graceMs = FORCE_GRACE_MS, + pollMs = FORCE_POLL_MS, + } = {}, +) { + if (!signalDetachedGroup(childPid, 'SIGTERM', signalGroup)) return + + const deadline = now() + graceMs + while (now() < deadline) { + await sleep(Math.min(pollMs, deadline - now())) + if (now() < deadline && !signalDetachedGroup(childPid, 0, signalGroup)) { + return + } + } + signalDetachedGroup(childPid, 'SIGKILL', signalGroup) +} + +export function mirrorChildExit( + { code, signal }, + { + exit = (exitCode) => process.exit(exitCode), + signalSelf = (childSignal) => { + process.removeAllListeners(childSignal) + process.kill(process.pid, childSignal) + }, + } = {}, +) { + if (code !== null) { + exit(code) + return + } + if (signal) { + signalSelf(signal) + return + } + exit(1) +} + +function defaultInstallSigint(handler) { + process.on('SIGINT', handler) + return () => process.off('SIGINT', handler) +} + +function defaultInstallSigterm(handler) { + process.on('SIGTERM', handler) + return () => process.off('SIGTERM', handler) +} + +function defaultInstallMessage(handler) { + process.on('message', handler) + return () => process.off('message', handler) +} + +function defaultInstallDisconnect(handler) { + process.on('disconnect', handler) + return () => process.off('disconnect', handler) +} + +function sendToParent(message) { + if (process.connected && process.send) process.send(message, () => {}) +} + +export async function runDevAnchor(mode, dependencies = {}) { + if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') { + throw new Error('usage: node scripts/dev.mjs --anchor ') + } + + const environment = dependencies.environment ?? process.env + const spawnChild = dependencies.spawnChild ?? spawn + const send = dependencies.send ?? sendToParent + const exit = dependencies.exit ?? ((code) => process.exit(code)) + const report = dependencies.report ?? ((message) => console.error(message)) + const ownPid = dependencies.ownPid ?? process.pid + const terminateGroup = + dependencies.terminateGroup ?? + ((pid) => terminateDetachedGroup(pid, { graceMs: FORCE_GRACE_MS })) + let released = false + let disconnectCleanup + + const installSigterm = dependencies.installSigterm ?? defaultInstallSigterm + installSigterm(() => {}) + const installMessage = dependencies.installMessage ?? defaultInstallMessage + installMessage((message) => { + if (message?.type === 'release' && !disconnectCleanup) { + released = true + exit(0) + } + }) + const installDisconnect = + dependencies.installDisconnect ?? defaultInstallDisconnect + installDisconnect(() => { + if (released || disconnectCleanup) return + disconnectCleanup = Promise.resolve() + .then(() => terminateGroup(ownPid)) + .then(() => exit(1)) + .catch((error) => { + report(`Dev anchor cleanup failed: ${error.message}`) + exit(1) + }) + }) + + const invocation = buildTauriInvocation(mode, environment, 'darwin') + let inner + try { + inner = spawnChild(invocation.command, invocation.args, { + env: invocation.env, + detached: false, + stdio: 'inherit', + }) + } catch { + send({ type: 'inner-error', code: 'SPAWN_FAILED' }) + return + } + if (!inner || typeof inner.once !== 'function') { + send({ type: 'inner-error', code: 'SPAWN_FAILED' }) + return + } + + const innerLifecycle = childLifecycle(inner) + try { + await innerLifecycle.spawned + } catch { + send({ type: 'inner-error', code: 'SPAWN_FAILED' }) + return + } + send({ type: 'ready' }) + innerLifecycle.result.then( + ({ code, signal }) => send({ type: 'inner-result', code, signal }), + () => send({ type: 'inner-error', code: 'INNER_FAILED' }), + ) +} + +function createAnchorChannel(anchor, anchorResult) { + let innerSettled = false + let readySettled = false + let resolveResult + let rejectResult + let resolveReady + let rejectReady + const innerResult = new Promise((resolve, reject) => { + resolveResult = resolve + rejectResult = reject + }) + const ready = new Promise((resolve, reject) => { + resolveReady = resolve + rejectReady = reject + }) + const settleInner = (callback, value) => { + if (innerSettled) return + innerSettled = true + callback(value) + } + const settleReady = (callback, value) => { + if (readySettled) return + readySettled = true + callback(value) + } + anchor.on('message', (message) => { + if (message?.type === 'ready') { + settleReady(resolveReady) + } else if (message?.type === 'inner-result') { + settleInner(resolveResult, { + code: message.code, + signal: message.signal, + }) + } else if (message?.type === 'inner-error') { + const error = new Error( + `inner dev command failed (${message.code ?? 'UNKNOWN'})`, + ) + settleReady(rejectReady, error) + settleInner(rejectResult, error) + } + }) + anchorResult.then( + () => { + const error = new Error( + 'dev group anchor exited before the inner command completed', + ) + settleReady(rejectReady, error) + settleInner(rejectResult, error) + }, + () => { + const error = new Error('dev group anchor failed') + settleReady(rejectReady, error) + settleInner(rejectResult, error) + }, + ) + ready.catch(() => {}) + innerResult.catch(() => {}) + return { innerResult, ready } +} + +function sendRelease(anchor) { + return new Promise((resolve, reject) => { + if (typeof anchor.send !== 'function') { + reject(new Error('dev group anchor IPC is unavailable')) + return + } + anchor.send({ type: 'release' }, (error) => { + if (error) reject(new Error('dev group anchor release failed')) + else resolve() + }) + }) +} + +function unsafeAnchorError() { + return new Error('dev group anchor identity is unsafe') +} + +export async function runDevCommand(mode, dependencies = {}) { + if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') { + throw new Error('usage: node scripts/dev.mjs ') + } + + const platform = dependencies.platform ?? process.platform + const environment = dependencies.environment ?? process.env + const spawnChild = dependencies.spawnChild ?? spawn + const exit = dependencies.exit ?? ((code) => process.exit(code)) + const report = dependencies.report ?? ((message) => console.error(message)) + const mirrorResult = dependencies.mirrorResult ?? mirrorChildExit + let recordPath + let pendingSigints = 0 + let dispatchSigint = () => { + pendingSigints += 1 + } + let removeSigint = () => {} + + if (platform === 'darwin') { + if (!environment.HOME) { + throw new Error('HOME is required to locate the dev instance record') + } + recordPath = join( + environment.HOME, + 'Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev/singleton-instance.json', + ) + const installSigint = dependencies.installSigint ?? defaultInstallSigint + removeSigint = installSigint(() => dispatchSigint()) ?? (() => {}) + } + + const tauriInvocation = buildTauriInvocation(mode, environment, platform) + const anchorInvocation = { + command: dependencies.execPath ?? process.execPath, + args: [fileURLToPath(import.meta.url), '--anchor', mode], + env: tauriInvocation.env, + detached: true, + } + const invocation = platform === 'darwin' ? anchorInvocation : tauriInvocation + let child + try { + child = spawnChild(invocation.command, invocation.args, { + env: invocation.env, + detached: invocation.detached, + stdio: + platform === 'darwin' + ? ['inherit', 'inherit', 'inherit', 'ipc'] + : 'inherit', + }) + } catch (error) { + removeSigint() + throw error + } + if (!child || typeof child.once !== 'function') { + removeSigint() + throw new Error('dev command failed to return a child process') + } + const outerLifecycle = childLifecycle(child) + + if (platform !== 'darwin') { + mirrorResult(await outerLifecycle.result) + return + } + + let groupState = 'safe' + let finalKillSent = false + const anchorChannel = createAnchorChannel(child, outerLifecycle.result) + outerLifecycle.result.then( + () => { + if (groupState !== 'released' && !finalKillSent) groupState = 'unsafe' + }, + () => { + groupState = 'unsafe' + }, + ) + + try { + await outerLifecycle.spawned + if (!Number.isInteger(child.pid) || child.pid <= 0) { + throw new Error('dev command did not provide a valid child PID') + } + await waitForPromise( + anchorChannel.ready, + SHUTDOWN_TIMEOUT_MS, + 'timed out waiting for the dev group anchor to initialize', + ) + } catch (error) { + removeSigint() + throw error + } + + const anchorPid = child.pid + const innerResult = anchorChannel.innerResult + let intercepted = false + let observedShutdownOperation + let anchorFinalization + + const signalGroup = dependencies.signalGroup ?? process.kill.bind(process) + const guardedSignalGroup = (pid, signal) => { + if (groupState === 'unsafe') throw unsafeAnchorError() + if (signal === 'SIGKILL') finalKillSent = true + try { + signalGroup(pid, signal) + } catch (error) { + if (error?.code === 'ESRCH') { + groupState = 'unsafe' + throw unsafeAnchorError() + } + throw error + } + } + + const assertGroupAbsent = () => { + try { + signalGroup(-anchorPid, 0) + } catch (error) { + if (error?.code === 'ESRCH') return + throw new Error('unable to verify that the dev process group exited', { + cause: error, + }) + } + throw new Error('dev process group still exists after anchor exit') + } + + const forceAnchor = async (budget) => { + if (groupState === 'unsafe') throw unsafeAnchorError() + groupState = 'terminating' + if (dependencies.terminateGroup) { + await dependencies.terminateGroup(anchorPid, budget) + return + } + const deadline = Date.now() + budget + await terminateDetachedGroup(anchorPid, { + graceMs: Math.max(0, budget - ANCHOR_EXIT_RESERVE_MS), + signalGroup: guardedSignalGroup, + }) + await waitForPromise( + outerLifecycle.result, + Math.max(1, deadline - Date.now()), + 'timed out waiting for the dev group anchor to exit', + ) + } + + const releaseAnchor = async (budget) => { + if (groupState === 'unsafe') throw unsafeAnchorError() + groupState = 'released' + const deadline = Date.now() + budget + await waitForPromise( + sendRelease(child), + Math.max(1, deadline - Date.now()), + 'timed out releasing the dev group anchor', + ) + await waitForPromise( + outerLifecycle.result, + Math.max(1, deadline - Date.now()), + 'timed out waiting for the released dev group anchor', + ) + assertGroupAbsent() + } + + const finalizeAnchorOnce = (kind, budget) => { + anchorFinalization ??= + kind === 'release' ? releaseAnchor(budget) : forceAnchor(budget) + return anchorFinalization + } + + const getUid = dependencies.getUid ?? (() => process.getuid()) + const requestQuit = + dependencies.requestQuit ?? + ((budget) => + requestDevQuit(() => readPrivateInstanceRecord(recordPath, getUid()), { + timeoutMs: budget, + })) + + const controller = createMacosShutdownController({ + requestQuit, + waitForPortClosed: (port, budget) => + (dependencies.waitForPortClosed ?? waitForPortClosed)(port, { + timeoutMs: budget, + }), + waitForChildExit: (budget) => + waitForPromise( + innerResult, + budget, + 'timed out waiting for the inner dev command to exit', + ), + finalizeGroup: (budget) => finalizeAnchorOnce('release', budget), + terminateGroup: (budget) => finalizeAnchorOnce('force', budget), + exit, + report, + timeoutMs: SHUTDOWN_TIMEOUT_MS, + }) + + dispatchSigint = () => { + intercepted = true + const operation = controller.handleSigint() + if (operation !== observedShutdownOperation) { + observedShutdownOperation = operation + void operation.catch((error) => { + report(`Forced dev shutdown failed: ${error.message}`) + exit(1) + }) + } + } + const queuedSigints = pendingSigints + pendingSigints = 0 + for (let index = 0; index < queuedSigints; index += 1) { + dispatchSigint() + } + + innerResult.then( + async (result) => { + if (intercepted) return + try { + await finalizeAnchorOnce('release', SHUTDOWN_TIMEOUT_MS) + if (!intercepted) mirrorResult(result) + } catch (error) { + if (!intercepted) { + report(`Dev command failed: ${error.message}`) + exit(1) + } + } + }, + (error) => { + if (!intercepted) { + report(`Dev command failed: ${error.message}`) + exit(1) + } + }, + ) +} + +export async function prepareDevRuntime(mode, dependencies = {}) { + const ensureService = dependencies.ensureService ?? ensureDevelopmentService + const prepareService = + dependencies.prepareService ?? prepareDevelopmentService + let serviceDirectory + if (mode === 'service') { + serviceDirectory = await ensureService() + } else if (mode === 'dev' || mode === 'trace' || mode === 'sidecar') { + serviceDirectory = await prepareService() + } + return { + mode: mode === 'service' ? 'dev' : mode, + serviceDirectory, + } +} + +async function run() { + if (process.argv[2] === '--anchor') { + await runDevAnchor(process.argv[3]) + } else { + const { mode, serviceDirectory } = await prepareDevRuntime(process.argv[2]) + if (serviceDirectory) { + process.env[developmentServiceDirectoryEnvironment] = serviceDirectory + } + await runDevCommand(mode) + } +} + +if ( + process.argv[1] && + pathToFileURL(process.argv[1]).href === import.meta.url +) { + run().catch((error) => { + console.error(`Unable to start dev command: ${error.message}`) + process.exit(1) + }) +} diff --git a/scripts/prebuild.mjs b/scripts/prebuild.mjs index b506f40af..29c11efe7 100644 --- a/scripts/prebuild.mjs +++ b/scripts/prebuild.mjs @@ -10,6 +10,7 @@ import { glob } from 'glob' import { HttpsProxyAgent } from 'https-proxy-agent' import { extract } from 'tar' +import { resolveServiceRelease } from './service-release.mjs' import { log_debug, log_error, log_info, log_success } from './utils.mjs' /** @@ -577,12 +578,6 @@ const resolveServicePermission = async () => { // ======================= // Other resource resolvers (service, mmdb, geosite, geoip, enableLoopback) // ======================= -const SERVICE_LATEST_URL = - 'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/latest' -const SERVICE_URL_PREFIX = - 'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download' -let SERVICE_VERSION - const SERVICE_BINARIES = [ 'clash-verge-service', 'clash-verge-service-install', @@ -598,53 +593,6 @@ function serviceFileInfo(name) { } } -function parseServiceVersionFromUrl(url) { - const match = url.match(/\/releases\/tag\/([^/?#]+)/) - return match ? decodeURIComponent(match[1]) : null -} - -async function getLatestServiceVersion() { - if (!FORCE) { - const cached = await getCachedVersion('SERVICE_VERSION') - if (cached) { - SERVICE_VERSION = cached - return - } - } - - const options = {} - const httpProxy = - process.env.HTTP_PROXY || - process.env.http_proxy || - process.env.HTTPS_PROXY || - process.env.https_proxy - if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy) - - try { - const response = await fetch(SERVICE_LATEST_URL, { - ...options, - method: 'GET', - redirect: 'follow', - }) - if (!response.ok) - throw new Error( - `Failed to fetch ${SERVICE_LATEST_URL}: ${response.status}`, - ) - - SERVICE_VERSION = parseServiceVersionFromUrl(response.url) - if (!SERVICE_VERSION) - throw new Error( - `Unable to resolve service release tag from ${response.url}`, - ) - - log_info(`Latest service version: ${SERVICE_VERSION}`) - await setCachedVersion('SERVICE_VERSION', SERVICE_VERSION) - } catch (err) { - log_error('Error fetching latest service version:', err.message) - process.exit(1) - } -} - async function findExtractedFile(dir, fileName) { const entries = await fsp.readdir(dir, { withFileTypes: true }) for (const entry of entries) { @@ -667,16 +615,15 @@ async function resolveServiceBundle() { } }) - if (!FORCE && files.every(({ targetPath }) => fs.existsSync(targetPath))) { - log_success('"clash-verge-service-ipc" already exists, skipping download') - return - } - - await getLatestServiceVersion() - - const archiveExt = platform === 'win32' ? 'zip' : 'tar.gz' - const archiveFile = `clash-verge-service-ipc-${SERVICE_VERSION}-${SIDECAR_HOST}.${archiveExt}` - const downloadURL = `${SERVICE_URL_PREFIX}/${SERVICE_VERSION}/${archiveFile}` + const cargoManifest = await fsp.readFile( + path.join(cwd, 'src-tauri', 'Cargo.toml'), + 'utf8', + ) + const { archiveFile, downloadURL } = resolveServiceRelease( + cargoManifest, + SIDECAR_HOST, + platform, + ) const tempDir = path.join(TEMP_DIR, 'clash-verge-service-ipc') const tempArchive = path.join(tempDir, archiveFile) diff --git a/scripts/service-release.mjs b/scripts/service-release.mjs new file mode 100644 index 000000000..0592c9206 --- /dev/null +++ b/scripts/service-release.mjs @@ -0,0 +1,23 @@ +const SERVICE_URL_PREFIX = + 'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download' + +export function resolveServiceRelease(cargoManifest, host, platform) { + const dependency = cargoManifest + .split(/\r?\n/) + .find((line) => line.trimStart().startsWith('clash_verge_service_ipc =')) + const packageVersion = dependency?.match(/\bversion\s*=\s*"([^"]+)"/)?.[1] + if (!packageVersion) { + throw new Error( + 'clash_verge_service_ipc dependency must declare an inline version', + ) + } + + const version = `v${packageVersion}` + const archiveExt = platform === 'win32' ? 'zip' : 'tar.gz' + const archiveFile = `clash-verge-service-ipc-${version}-${host}.${archiveExt}` + return { + version, + archiveFile, + downloadURL: `${SERVICE_URL_PREFIX}/${version}/${archiveFile}`, + } +} diff --git a/scripts/service-release.test.mjs b/scripts/service-release.test.mjs new file mode 100644 index 000000000..2829d081a --- /dev/null +++ b/scripts/service-release.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +import { resolveServiceRelease } from './service-release.mjs' + +test('service prebuild release follows the Cargo dependency version', async () => { + const cargoManifest = await readFile( + new URL('../src-tauri/Cargo.toml', import.meta.url), + 'utf8', + ) + const dependencyVersion = cargoManifest + .split(/\r?\n/) + .find((line) => line.startsWith('clash_verge_service_ipc =')) + ?.match(/\bversion\s*=\s*"([^"]+)"/)?.[1] + + assert.ok(dependencyVersion) + const releaseVersion = `v${dependencyVersion}` + assert.deepEqual( + resolveServiceRelease(cargoManifest, 'x86_64-pc-windows-msvc', 'win32'), + { + version: releaseVersion, + archiveFile: `clash-verge-service-ipc-${releaseVersion}-x86_64-pc-windows-msvc.zip`, + downloadURL: `https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download/${releaseVersion}/clash-verge-service-ipc-${releaseVersion}-x86_64-pc-windows-msvc.zip`, + }, + ) +}) + +test('service prebuild rejects a dependency without an explicit version', () => { + assert.throws( + () => + resolveServiceRelease( + 'clash_verge_service_ipc = { path = "../service" }', + 'x86_64-pc-windows-msvc', + 'win32', + ), + /must declare an inline version/, + ) +}) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 253d6782f..940ab9972 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,7 +17,11 @@ crate-type = ["staticlib", "cdylib", "rlib"] [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] -verge-dev = ["clash_verge_logger/color"] +verge-dev = [ + "clash_verge_logger/color", + "clash_verge_service_ipc/development-channel", +] +dev-sidecar = ["verge-dev"] tauri-dev = [] tokio-trace = ["console-subscriber"] clippy = ["tauri/test"] @@ -71,7 +75,7 @@ reqwest = { version = "0.13.4", features = [ "form", ] } regex = "1.12.4" -sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", features = [ +sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", branch = "main", features = [ "guard", ] } network-interface = { version = "2.0.5", features = ["serde"] } @@ -99,9 +103,9 @@ tauri-plugin-devtools = { version = "2.1.0" } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "fix/api-robustness" } clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" } async-trait = "0.1.89" -clash_verge_service_ipc = { version = "2.3.3", features = [ +clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.5.2", version = "2.5.2", features = [ "client", -], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" } +] } arc-swap = "1.9.2" tokio-rustls = "0.26.4" rustls = { version = "0.23.41", features = ["ring"] } @@ -113,6 +117,9 @@ windows-sys.workspace = true bytes = "1.12.0" tokio-util = { version = "0.7.18", features = ["time"] } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.4" objc2-foundation = { version = "0.3.2", features = [ @@ -140,6 +147,7 @@ objc2-app-kit = { version = "0.3.2", features = [ deelevate = { workspace = true } runas = "=1.2.0" winreg = "0.56.0" +windows-service = "0.8.1" windows = { version = "0.62.2", features = ["Win32_Globalization"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] @@ -149,6 +157,8 @@ tauri-plugin-updater = "2.10.1" [dev-dependencies] criterion = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } +warp = { version = "0.4.3", features = ["test"] } [lints] workspace = true diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index 180d5cefa..46892088e 100755 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -12,7 +12,6 @@ "updater:default", "updater:allow-check", "updater:allow-download-and-install", - "process:allow-restart", "deep-link:default", "autostart:allow-enable", "autostart:allow-disable", diff --git a/src-tauri/capabilities/migrated.json b/src-tauri/capabilities/migrated.json index 097c622fb..8248660a9 100644 --- a/src-tauri/capabilities/migrated.json +++ b/src-tauri/capabilities/migrated.json @@ -74,7 +74,6 @@ "global-shortcut:allow-register-all", "global-shortcut:allow-unregister", "global-shortcut:allow-unregister-all", - "process:allow-restart", "process:allow-exit", "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-text", diff --git a/src-tauri/src/cmd/network.rs b/src-tauri/src/cmd/network.rs index 6068ab998..c8e932327 100644 --- a/src-tauri/src/cmd/network.rs +++ b/src-tauri/src/cmd/network.rs @@ -60,6 +60,11 @@ pub async fn get_auto_proxy() -> CmdResult { Ok(map) } +#[tauri::command] +pub fn get_embedded_server_port() -> CmdResult { + crate::utils::server::embedded_server_port().stringify_err() +} + /// 获取系统主机名 #[tauri::command] pub fn get_system_hostname() -> String { diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index 9d75f4e0e..f6a4b2a06 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -21,7 +21,6 @@ use clash_verge_logging::{Type, logging, logging_error}; use scopeguard::defer; use smartstring::alias::String; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false); @@ -276,12 +275,14 @@ async fn handle_update_error( Ok(ValidationOutcome::invalid_from_message(message)) } -async fn handle_timeout(current_profile: Option<&String>) -> CmdResult { - let timeout_msg: String = "配置更新超时(30秒),可能是配置验证或核心通信阻塞".into(); - logging!(error, Type::Cmd, "{}", timeout_msg); - discard_and_restore(current_profile).await?; - handle::Handle::notice_message("config_validate::timeout", timeout_msg.clone()); - Ok(ValidationOutcome::invalid_from_message(timeout_msg)) +async fn run_profile_config_update_transition( + update_config: Update, +) -> anyhow::Result +where + Update: FnOnce() -> UpdateFuture, + UpdateFuture: std::future::Future>, +{ + update_config().await } async fn perform_config_update( @@ -291,14 +292,12 @@ async fn perform_config_update( defer! { CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release); } - let update_result = - tokio::time::timeout(Duration::from_secs(30), CoreManager::global().update_config_forced()).await; + let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await; match update_result { - Ok(Ok(outcome)) if outcome.is_valid() => handle_success(current_value).await, - Ok(Ok(outcome)) => handle_validation_failure(outcome, current_profile).await, - Ok(Err(e)) => handle_update_error(e, current_profile).await, - Err(_) => handle_timeout(current_profile).await, + Ok(outcome) if outcome.is_valid() => handle_success(current_value).await, + Ok(outcome) => handle_validation_failure(outcome, current_profile).await, + Err(e) => handle_update_error(e, current_profile).await, } } @@ -430,9 +429,32 @@ pub async fn get_next_update_time(uid: String) -> CmdResult> { #[cfg(test)] mod tests { - use super::commit_current_profile; + use super::{commit_current_profile, run_profile_config_update_transition}; use crate::config::{IProfiles, PrfItem}; + use crate::core::validate::ValidationOutcome; use clash_verge_draft::Draft; + use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + task::Poll, + time::Duration, + }; + use tokio::sync::Barrier; + + struct CancellationProbe { + cancelled: Arc, + completed: Arc, + } + + impl Drop for CancellationProbe { + fn drop(&mut self) { + if !self.completed.load(Ordering::Acquire) { + self.cancelled.store(true, Ordering::Release); + } + } + } fn profile(uid: &str) -> PrfItem { PrfItem { @@ -467,4 +489,42 @@ mod tests { assert!(committed.get_item("new").is_ok()); Ok(()) } + + #[tokio::test(start_paused = true)] + async fn profile_config_update_runs_past_former_deadline_without_cancellation() -> anyhow::Result<()> { + let update_started = Arc::new(Barrier::new(2)); + let release_update = Arc::new(Barrier::new(2)); + let update_cancelled = Arc::new(AtomicBool::new(false)); + let update_completed = Arc::new(AtomicBool::new(false)); + + let mut update = Box::pin(run_profile_config_update_transition({ + let update_started = Arc::clone(&update_started); + let release_update = Arc::clone(&release_update); + let update_cancelled = Arc::clone(&update_cancelled); + let update_completed = Arc::clone(&update_completed); + move || async move { + let _probe = CancellationProbe { + cancelled: update_cancelled, + completed: Arc::clone(&update_completed), + }; + update_started.wait().await; + release_update.wait().await; + update_completed.store(true, Ordering::Release); + Ok(ValidationOutcome::Valid) + } + })); + + assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending)); + update_started.wait().await; + tokio::time::advance(Duration::from_secs(31)).await; + + assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending)); + assert!(!update_cancelled.load(Ordering::Acquire)); + + release_update.wait().await; + assert!(update.await?.is_valid()); + assert!(update_completed.load(Ordering::Acquire)); + assert!(!update_cancelled.load(Ordering::Acquire)); + Ok(()) + } } diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index 862b2cbb3..29530f28a 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -1,7 +1,21 @@ use super::{CmdResult, StringifyErr as _}; -use crate::core::service::{self, SERVICE_MANAGER, ServiceStatus}; +use crate::core::{ + CoreManager, + service::{self, SERVICE_MANAGER, ServiceStatus}, +}; async fn execute_service_operation_sync(status: ServiceStatus, op_type: &str) -> CmdResult { + let manager = CoreManager::global(); + let _lifecycle = manager.lifecycle_lock.lock().await; + if matches!( + &status, + ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired + ) { + manager + .controlled_stop_core_inner() + .await + .map_err(|error| format!("{op_type} Service failed: {error:#}"))?; + } SERVICE_MANAGER .handle_service_status(status) .await @@ -15,7 +29,10 @@ pub async fn install_service() -> CmdResult { #[tauri::command] pub async fn uninstall_service() -> CmdResult { - execute_service_operation_sync(ServiceStatus::UninstallRequired, "Uninstall").await + CoreManager::global() + .uninstall_service_and_start_sidecar() + .await + .map_err(|error| format!("Uninstall Service failed: {error:#}").into()) } #[tauri::command] @@ -30,6 +47,18 @@ pub async fn repair_service() -> CmdResult { #[tauri::command] pub async fn is_service_available() -> CmdResult { - service::is_service_available().await.stringify_err()?; - Ok(true) + Ok(service::is_service_available().await) +} + +#[tauri::command] +pub async fn get_service_install_state() -> CmdResult { + Ok(SERVICE_MANAGER.install_state().await) +} + +#[tauri::command] +pub async fn continue_with_sidecar() -> CmdResult { + crate::core::CoreManager::global() + .continue_with_sidecar() + .await + .stringify_err() } diff --git a/src-tauri/src/config/clash.rs b/src-tauri/src/config/clash.rs index 6e92503e1..835862a80 100644 --- a/src-tauri/src/config/clash.rs +++ b/src-tauri/src/config/clash.rs @@ -1,6 +1,6 @@ use crate::config::Config; use crate::constants::{network, tun as tun_const}; -use crate::utils::dirs::{ipc_path, path_to_str}; +use crate::utils::dirs::{path_to_str, sidecar_ipc_path}; use crate::utils::{dirs, help}; use anyhow::Result; use clash_verge_logging::{Type, logging}; @@ -326,7 +326,7 @@ impl IClashTemp { pub fn guard_external_controller_ipc() -> String { // 总是使用当前的 IPC 路径,确保配置文件与运行时路径一致 - ipc_path() + sidecar_ipc_path() .ok() .and_then(|path| path_to_str(&path).ok().map(|s| s.into())) .unwrap_or_else(|| { diff --git a/src-tauri/src/config/config.rs b/src-tauri/src/config/config.rs index cf665850a..c2c3d713f 100644 --- a/src-tauri/src/config/config.rs +++ b/src-tauri/src/config/config.rs @@ -5,7 +5,7 @@ use crate::{ core::{ CoreManager, handle::{self, Handle}, - service, tray, + tray, validate::CoreConfigValidator, }, enhance, @@ -18,8 +18,11 @@ use clash_verge_draft::Draft; use clash_verge_logging::{Type, logging, logging_error}; use serde_yaml_ng::{Mapping, Value}; use smartstring::alias::String; -use std::{collections::HashSet, path::PathBuf}; -use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin; +use std::{ + collections::HashSet, + path::PathBuf, + sync::atomic::{AtomicBool, Ordering}, +}; use tokio::sync::OnceCell; use tokio::time::sleep; @@ -30,6 +33,8 @@ pub struct Config { runtime_config: Draft, } +static TUN_SESSION_SUPPRESSED: AtomicBool = AtomicBool::new(false); + impl Config { pub async fn global() -> &'static Self { static CONFIG: OnceCell = OnceCell::const_new(); @@ -73,23 +78,35 @@ impl Config { let verge = Self::verge().await.latest_arc(); clash_verge_i18n::sync_locale(verge.language.as_deref()); - // init Tun mode - let handle = Handle::app_handle(); - let is_admin = is_current_app_handle_admin(handle); - let is_service_available = service::is_service_available().await.is_ok(); - if !is_admin && !is_service_available { - let verge = Self::verge().await; - verge.edit_draft(|d| { - d.enable_tun_mode = Some(false); - }); - verge.apply(); - let _ = tray::Tray::global().update_menu().await; + Ok(()) + } - // 分离数据获取和异步调用避免Send问题 - let verge_data = Self::verge().await.latest_arc(); - logging_error!(Type::Core, verge_data.save_file().await); - } + pub fn tun_suppressed_for_session() -> bool { + TUN_SESSION_SUPPRESSED.load(Ordering::Acquire) + } + pub(crate) async fn suppress_tun_for_session() { + TUN_SESSION_SUPPRESSED.store(true, Ordering::Release); + Handle::refresh_verge(); + let _ = tray::Tray::global().update_menu().await; + } + + pub(crate) async fn restore_tun_for_session() { + TUN_SESSION_SUPPRESSED.store(false, Ordering::Release); + Handle::refresh_verge(); + let _ = tray::Tray::global().update_menu().await; + } + + pub(crate) async fn disable_tun_and_persist() -> Result<()> { + TUN_SESSION_SUPPRESSED.store(false, Ordering::Release); + let verge = Self::verge().await; + verge.edit_draft(|draft| { + draft.enable_tun_mode = Some(false); + }); + verge.apply(); + verge.data_arc().save_file().await?; + Handle::refresh_verge(); + let _ = tray::Tray::global().update_menu().await; Ok(()) } diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index 4f8749eb2..8a6b1ebc9 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -556,10 +556,6 @@ impl IVerge { patch!(enable_external_controller); } - pub const fn get_singleton_port() -> u16 { - crate::constants::network::ports::SINGLETON_SERVER - } - /// 获取日志等级 pub fn get_log_level(&self) -> LevelFilter { if let Some(level) = self.app_log_level.as_ref() { diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index 1ff4e1496..bd0451935 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -11,11 +11,6 @@ pub mod network { pub const DEFAULT_MIXED: u16 = 7897; pub const DEFAULT_SOCKS: u16 = 7898; pub const DEFAULT_HTTP: u16 = 7899; - - #[cfg(not(feature = "verge-dev"))] - pub const SINGLETON_SERVER: u16 = 33331; - #[cfg(feature = "verge-dev")] - pub const SINGLETON_SERVER: u16 = 11233; } } diff --git a/src-tauri/src/core/handle.rs b/src-tauri/src/core/handle.rs index f8a32ebef..a0b40acbf 100644 --- a/src-tauri/src/core/handle.rs +++ b/src-tauri/src/core/handle.rs @@ -80,6 +80,10 @@ impl Handle { self.is_exiting.store(true, Ordering::Release); } + pub fn clear_is_exiting(&self) { + self.is_exiting.store(false, Ordering::Release); + } + pub fn is_exiting(&self) -> bool { self.is_exiting.load(Ordering::Acquire) } diff --git a/src-tauri/src/core/logger.rs b/src-tauri/src/core/logger.rs index fab0e7a4c..74c6af805 100644 --- a/src-tauri/src/core/logger.rs +++ b/src-tauri/src/core/logger.rs @@ -8,7 +8,6 @@ use std::{ use anyhow::{Result, bail}; use clash_verge_logging::{Type, logging}; -use clash_verge_service_ipc::WriterConfig; use compact_str::CompactString; use flexi_logger::{ Cleanup, Criterion, DeferredNow, FileSpec, LogSpecBuilder, LogSpecification, LoggerHandle, @@ -20,7 +19,7 @@ use parking_lot::{Mutex, RwLock}; use crate::{ core::service, singleton, - utils::dirs::{self, service_log_dir, sidecar_log_dir}, + utils::dirs::{self, sidecar_log_dir}, }; pub struct Logger { @@ -180,10 +179,9 @@ impl Logger { *self.sidecar_file_writer.write() = Some(sidecar_writer); // update service writer config - if service::is_service_ipc_path_exists() && service::is_service_available().await.is_ok() { - let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into(); - clash_verge_service_ipc::update_writer(&WriterConfig { - directory: service_log_dir, + if service::is_service_available().await { + service::update_writer_by_service(&clash_verge_service_ipc::WriterConfig { + directory: String::new(), max_log_size: log_max_size * 1024, max_log_files: log_max_count, }) @@ -225,17 +223,4 @@ impl Logger { logging!(error, Type::System, "failed to get sidecar file log writer"); } } - - pub fn service_writer_config(&self) -> Result { - let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into(); - let log_max_size = self.log_max_size.load(Ordering::SeqCst); - let log_max_count = self.log_max_count.load(Ordering::SeqCst); - let writer_config = WriterConfig { - directory: service_log_dir, - max_log_size: log_max_size * 1024, - max_log_files: log_max_count, - }; - - Ok(writer_config) - } } diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index ddf40ee8c..a5b8435d6 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -1,4 +1,4 @@ -use super::CoreManager; +use super::{CoreManager, RunningMode}; use crate::{ config::{Config, ConfigType, runtime::IRuntime}, constants::timing, @@ -136,6 +136,27 @@ impl CoreManager { } async fn apply_config(&self, path: PathBuf) -> Result<()> { + if matches!(*self.get_running_mode(), RunningMode::Service) { + let _lifecycle = self.lifecycle_lock.lock().await; + if !matches!(*self.get_running_mode(), RunningMode::Service) { + Config::runtime().await.discard(); + return Err(anyhow!("core mode changed while applying service configuration")); + } + let result = self.replace_service_core_with_config(&path).await; + + return match result { + Ok(()) => { + Config::runtime().await.apply(); + logging!(info, Type::Core, "Configuration materialized and applied by service"); + Ok(()) + } + Err(error) => { + Config::runtime().await.discard(); + Err(error) + } + }; + } + let path = dirs::path_to_str(&path)?; match self.reload_config(path).await { Ok(_) => { diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index 5a690285f..bae72f709 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -3,11 +3,13 @@ use crate::cmd::StringifyErr as _; use crate::config::{Config, IVerge}; use crate::core::handle::Handle; use crate::core::manager::CLASH_LOGGER; +use crate::core::proxy_control; use crate::core::service::{SERVICE_MANAGER, ServiceStatus}; use anyhow::Result; use clash_verge_logging::{Type, logging}; use scopeguard::defer; use smartstring::alias::String; +use std::path::Path; use tauri_plugin_clash_verge_sysinfo; #[cfg(target_os = "windows")] use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin; @@ -17,6 +19,236 @@ const fn should_wait_for_service(tun_enabled: bool, service_ready: bool, is_admi tun_enabled && !service_ready && !is_admin } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StartupDecision { + Service, + Sidecar, + Wait, +} + +const fn startup_decision(status: &ServiceStatus, service_required: bool) -> StartupDecision { + match status { + ServiceStatus::Ready => StartupDecision::Service, + ServiceStatus::NotInstalled if !service_required => StartupDecision::Sidecar, + ServiceStatus::SidecarAllowed => StartupDecision::Sidecar, + ServiceStatus::Checking + | ServiceStatus::NotInstalled + | ServiceStatus::NeedsReinstall + | ServiceStatus::InstallRequired + | ServiceStatus::Unavailable(_) => StartupDecision::Wait, + ServiceStatus::UninstallRequired | ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired => { + StartupDecision::Wait + } + } +} + +const fn can_allow_sidecar_for_session(running_mode: &RunningMode, service_status: &ServiceStatus) -> bool { + matches!( + (running_mode, service_status), + ( + RunningMode::NotRunning, + ServiceStatus::NotInstalled + | ServiceStatus::NeedsReinstall + | ServiceStatus::InstallRequired + | ServiceStatus::Unavailable(_) + ) | (RunningMode::Sidecar, ServiceStatus::InstallRequired) + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ProxyRestoreExpectation { + mode: RunningMode, + core_readiness_generation: u64, + service_owner_generation: Option, +} + +impl ProxyRestoreExpectation { + const fn capture( + mode: RunningMode, + core_readiness_generation: Option, + service_owner_generation: u64, + ) -> Option { + let Some(core_readiness_generation) = core_readiness_generation else { + return None; + }; + match mode { + RunningMode::Service => Some(Self { + mode, + core_readiness_generation, + service_owner_generation: Some(service_owner_generation), + }), + RunningMode::Sidecar => Some(Self { + mode, + core_readiness_generation, + service_owner_generation: None, + }), + RunningMode::NotRunning => None, + } + } + + fn is_valid( + self, + current_mode: &RunningMode, + current_core_readiness_generation: Option, + current_service_owner_generation: u64, + ) -> bool { + self.mode == *current_mode + && Some(self.core_readiness_generation) == current_core_readiness_generation + && self + .service_owner_generation + .is_none_or(|expected| expected == current_service_owner_generation) + } +} + +async fn run_controlled_stop_transition( + is_macos: bool, + running_mode: RunningMode, + stop_guard: StopGuard, + clear_proxy: Clear, + stop_core: Stop, +) -> Result<()> +where + StopGuard: FnOnce() -> StopGuardFuture, + StopGuardFuture: std::future::Future, + Clear: FnOnce() -> ClearFuture, + ClearFuture: std::future::Future>, + Stop: FnOnce() -> StopFuture, + StopFuture: std::future::Future>, +{ + match running_mode { + RunningMode::NotRunning => {} + RunningMode::Service if is_macos => stop_guard().await, + RunningMode::Service | RunningMode::Sidecar => clear_proxy().await?, + } + stop_core().await +} + +async fn run_core_start_transition( + start_core: Start, + core_is_ready: Ready, + apply_proxy: Apply, +) -> Result<()> +where + Start: FnOnce() -> StartFuture, + StartFuture: std::future::Future>, + Ready: FnOnce() -> bool, + Apply: FnOnce() -> ApplyFuture, + ApplyFuture: std::future::Future>, +{ + start_core().await?; + if core_is_ready() { + apply_proxy().await?; + } + Ok(()) +} + +async fn run_ready_core_start_transition( + start_core: Start, + core_is_ready: Ready, + apply_proxy: Apply, +) -> Result<()> +where + Start: FnOnce() -> StartFuture, + StartFuture: std::future::Future>, + Ready: FnOnce() -> bool, + Apply: FnOnce() -> ApplyFuture, + ApplyFuture: std::future::Future>, +{ + start_core().await?; + if !core_is_ready() { + anyhow::bail!("core did not become ready after start"); + } + apply_proxy().await +} + +async fn run_sidecar_termination_transition( + clear_proxy: Clear, + terminate_sidecar: Terminate, +) -> Result<()> +where + Clear: FnOnce() -> ClearFuture, + ClearFuture: std::future::Future>, + Terminate: FnOnce(), +{ + clear_proxy().await?; + terminate_sidecar(); + Ok(()) +} + +async fn run_service_config_replacement_transition< + StopGuard, + StopGuardFuture, + Clear, + ClearFuture, + Stop, + StopFuture, + Start, + StartFuture, + Ready, + Restore, + RestoreFuture, +>( + is_macos: bool, + stop_guard: StopGuard, + clear_proxy: Clear, + stop_core: Stop, + start_core: Start, + core_is_ready: Ready, + restore_proxy: Restore, +) -> Result<()> +where + StopGuard: FnOnce() -> StopGuardFuture, + StopGuardFuture: std::future::Future, + Clear: FnOnce() -> ClearFuture, + ClearFuture: std::future::Future>, + Stop: FnOnce() -> StopFuture, + StopFuture: std::future::Future>, + Start: FnOnce() -> StartFuture, + StartFuture: std::future::Future>, + Ready: FnOnce() -> bool, + Restore: FnOnce() -> RestoreFuture, + RestoreFuture: std::future::Future>, +{ + run_controlled_stop_transition(is_macos, RunningMode::Service, stop_guard, clear_proxy, stop_core).await?; + run_ready_core_start_transition(start_core, core_is_ready, restore_proxy).await +} + +pub(super) async fn run_core_replacement_transition( + stop_core: Stop, + start_core: Start, + restore_proxy: Restore, +) -> Result<()> +where + Stop: FnOnce() -> StopFuture, + StopFuture: std::future::Future>, + Start: FnOnce() -> StartFuture, + StartFuture: std::future::Future>, + Restore: FnOnce() -> RestoreFuture, + RestoreFuture: std::future::Future>, +{ + stop_core().await?; + start_core().await?; + restore_proxy().await +} + +async fn run_uninstall_transition( + uninstall: Uninstall, + prepare_sidecar: Prepare, + start_sidecar: Start, +) -> Result<()> +where + Uninstall: FnOnce() -> UninstallFuture, + UninstallFuture: std::future::Future>, + Prepare: FnOnce() -> PrepareFuture, + PrepareFuture: std::future::Future>, + Start: FnOnce() -> StartFuture, + StartFuture: std::future::Future>, +{ + uninstall().await?; + prepare_sidecar().await?; + start_sidecar().await +} + /// sidecar→service 交接结果 #[cfg(target_os = "windows")] enum HandoffOutcome { @@ -29,9 +261,162 @@ enum HandoffOutcome { } impl CoreManager { + async fn rollback_failed_start(&self) { + proxy_control::stop_guard().await; + crate::utils::server::set_pac_available(false); + self.invalidate_core_readiness(); + self.set_running_mode(RunningMode::NotRunning); + } + pub async fn start_core(&self) -> Result<()> { let _life = self.lifecycle_lock.lock().await; - self.start_core_inner().await + run_core_start_transition( + || self.start_core_inner(), + || !matches!(*self.get_running_mode(), RunningMode::NotRunning), + || self.apply_proxy_after_start(), + ) + .await + } + + pub async fn continue_with_sidecar(&self) -> Result<()> { + if !self.try_start_config_update() { + anyhow::bail!("configuration update is already running"); + } + defer! { + self.finish_config_update(); + } + let _life = self.lifecycle_lock.lock().await; + let status = SERVICE_MANAGER.current().await; + let mode = self.get_running_mode(); + if !can_allow_sidecar_for_session(&mode, &status) { + anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}"); + } + proxy_control::stop_guard().await; + Config::suppress_tun_for_session().await; + Config::generate().await?; + SERVICE_MANAGER.allow_sidecar_for_session()?; + let result = async { + self.start_core_inner().await?; + if !matches!(*self.get_running_mode(), RunningMode::Sidecar) { + anyhow::bail!("Sidecar did not become ready"); + } + self.apply_proxy_after_start().await + } + .await; + if let Err(error) = &result { + SERVICE_MANAGER.mark_unavailable(format!("Sidecar startup failed: {error:#}")); + if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await { + return Err(anyhow::anyhow!( + "Sidecar startup failed: {error:#}; failed to clear proxy before rollback: {cleanup_error:#}" + )); + } + } + result + } + + pub async fn uninstall_service_and_start_sidecar(&self) -> Result<()> { + if !self.try_start_config_update() { + anyhow::bail!("configuration update is already running"); + } + defer! { + self.finish_config_update(); + } + let _life = self.lifecycle_lock.lock().await; + + self.controlled_stop_core_inner().await?; + + let result = run_uninstall_transition( + || async { + SERVICE_MANAGER + .handle_service_status(ServiceStatus::UninstallRequired) + .await + }, + || async { + Config::disable_tun_and_persist().await?; + Config::generate().await + }, + || async { + self.start_core_inner().await?; + if !matches!(*self.get_running_mode(), RunningMode::Sidecar) { + anyhow::bail!("Sidecar did not become ready after service uninstall"); + } + self.apply_proxy_after_start().await + }, + ) + .await; + + if let Err(error) = &result + && let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await + { + return Err(anyhow::anyhow!( + "Service uninstall transition failed: {error:#}; failed to clear proxy before rollback: {cleanup_error:#}" + )); + } + result + } + + async fn rollback_failed_sidecar_transition(&self) -> Result<()> { + if matches!(*self.get_running_mode(), RunningMode::Sidecar) { + self.stop_sidecar_after_proxy_clear().await?; + } + self.rollback_failed_start().await; + self.after_core_process(); + Ok(()) + } + + async fn stop_sidecar_after_proxy_clear(&self) -> Result<()> { + run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await + } + + /// Replaces a Service-owned core while the caller holds `lifecycle_lock`. + pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> { + let result = run_service_config_replacement_transition( + cfg!(target_os = "macos"), + proxy_control::stop_guard, + proxy_control::clear, + || self.stop_core_unprepared_inner(), + || self.start_core_by_service_with_config(config_file), + || { + matches!(*self.get_running_mode(), RunningMode::Service) + && self.current_core_readiness_generation().is_some() + }, + || self.apply_proxy_after_start(), + ) + .await; + self.after_core_process(); + result + } + + pub(crate) async fn apply_proxy_after_start(&self) -> Result<()> { + let expectation = ProxyRestoreExpectation::capture( + *self.get_running_mode(), + self.current_core_readiness_generation(), + crate::core::service::owner_monitor_generation(), + ) + .ok_or_else(|| anyhow::anyhow!("cannot apply system proxy before core readiness"))?; + proxy_control::apply().await?; + if !expectation.is_valid( + self.get_running_mode().as_ref(), + self.current_core_readiness_generation(), + crate::core::service::owner_monitor_generation(), + ) { + let clear_result = proxy_control::clear().await; + proxy_control::stop_guard().await; + clear_result?; + anyhow::bail!("core readiness changed while applying system proxy"); + } + proxy_control::refresh_guard().await?; + if !expectation.is_valid( + self.get_running_mode().as_ref(), + self.current_core_readiness_generation(), + crate::core::service::owner_monitor_generation(), + ) { + let clear_result = proxy_control::clear().await; + proxy_control::stop_guard().await; + clear_result?; + anyhow::bail!("core readiness changed while refreshing the system proxy guard"); + } + Ok(()) } /// 调用者须已持有 `lifecycle_lock`。 @@ -51,7 +436,12 @@ impl CoreManager { return Ok(()); } - self.prepare_startup().await; + let startup = self.prepare_startup().await; + if matches!(startup, StartupDecision::Wait) { + self.rollback_failed_start().await; + self.after_core_process(); + return Ok(()); + } defer! { self.after_core_process(); } @@ -62,14 +452,17 @@ impl CoreManager { return Ok(()); } - let result = match *self.get_running_mode() { - RunningMode::Service => self.start_core_by_service().await, - RunningMode::NotRunning | RunningMode::Sidecar => self.start_core_by_sidecar().await, + let result = match startup { + StartupDecision::Service => self.start_core_by_service().await, + StartupDecision::Sidecar => self.start_core_by_sidecar().await, + StartupDecision::Wait => Ok(()), }; - // 启动失败时回滚 mode,允许后续重试。 + // No startup failure may leave a locally reported running mode. PAC is + // still fail-closed, and any service owner monitor will exit after this + // transition instead of treating an unconfirmed core as running. if result.is_err() { - self.set_running_mode(RunningMode::NotRunning); + self.rollback_failed_start().await; return result; } @@ -84,11 +477,24 @@ impl CoreManager { pub async fn stop_core(&self) -> Result<()> { let _life = self.lifecycle_lock.lock().await; - self.stop_core_inner().await + self.controlled_stop_core_inner().await } /// 调用者须已持有 `lifecycle_lock`。 - async fn stop_core_inner(&self) -> Result<()> { + pub(crate) async fn controlled_stop_core_inner(&self) -> Result<()> { + let running_mode = *self.get_running_mode(); + run_controlled_stop_transition( + cfg!(target_os = "macos"), + running_mode, + proxy_control::stop_guard, + proxy_control::clear, + || self.stop_core_unprepared_inner(), + ) + .await + } + + /// 调用者须已持有 `lifecycle_lock`,且已完成受控代理清理。 + async fn stop_core_unprepared_inner(&self) -> Result<()> { CLASH_LOGGER.clear_logs().await; defer! { self.after_core_process(); @@ -97,7 +503,7 @@ impl CoreManager { match *self.get_running_mode() { RunningMode::Service => self.stop_core_by_service().await, RunningMode::Sidecar => { - self.stop_core_by_sidecar(); + self.stop_core_by_sidecar_unprepared(); Ok(()) } RunningMode::NotRunning => Ok(()), @@ -108,8 +514,18 @@ impl CoreManager { // 持锁覆盖 stop+start,避免生命周期操作插入。 let _life = self.lifecycle_lock.lock().await; logging!(info, Type::Core, "Restarting core"); - self.stop_core_inner().await?; - self.start_core_inner().await + run_core_replacement_transition( + || self.controlled_stop_core_inner(), + || async { + self.start_core_inner().await?; + if matches!(*self.get_running_mode(), RunningMode::NotRunning) { + anyhow::bail!("core did not become ready after restart"); + } + Ok(()) + }, + || self.apply_proxy_after_start(), + ) + .await } pub async fn change_core(&self, clash_core: &String) -> Result<(), String> { @@ -129,16 +545,22 @@ impl CoreManager { Ok(()) } - async fn prepare_startup(&self) { + async fn prepare_startup(&self) -> StartupDecision { #[cfg(target_os = "windows")] self.wait_for_service_if_needed().await; - self.set_running_mode(match SERVICE_MANAGER.current().await { - ServiceStatus::Ready => RunningMode::Service, - _ => RunningMode::Sidecar, - }); + + let service_required = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false) + && !Config::tun_suppressed_for_session(); + if service_required + && matches!(SERVICE_MANAGER.current().await, ServiceStatus::NotInstalled) + && SERVICE_MANAGER.require_install_for_session().is_err() + { + return StartupDecision::Wait; + } + startup_decision(&SERVICE_MANAGER.current().await, service_required) } - fn after_core_process(&self) { + pub(in crate::core) fn after_core_process(&self) { let app_handle = Handle::app_handle(); tauri_plugin_clash_verge_sysinfo::set_app_core_mode(app_handle, self.get_running_mode().to_string()); } @@ -201,7 +623,8 @@ impl CoreManager { use std::time::Instant; // 仅 TUN 模式需要服务交接 - let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false); + let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false) + && !Config::tun_suppressed_for_session(); if !needs_service { return; } @@ -250,20 +673,25 @@ impl CoreManager { }); } - /// 服务就绪后停止 sidecar,再以 service 重启内核 #[cfg(target_os = "windows")] - async fn try_handoff_sidecar_to_service(&self) -> HandoffOutcome { + async fn refresh_service_readiness_for_handoff() -> bool { use crate::core::service; // 主动刷新服务状态,避免缓存状态阻止交接 if !service::is_service_ipc_path_exists() { - return HandoffOutcome::NotReady; + return false; } if SERVICE_MANAGER.init().await.is_err() { - return HandoffOutcome::NotReady; + return false; } let _ = SERVICE_MANAGER.refresh().await; - if !matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready) { + matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready) + } + + /// 服务就绪后停止 sidecar,再以 service 重启内核 + #[cfg(target_os = "windows")] + async fn try_handoff_sidecar_to_service(&self) -> HandoffOutcome { + if !Self::refresh_service_readiness_for_handoff().await { return HandoffOutcome::NotReady; } @@ -281,6 +709,7 @@ impl CoreManager { // 持锁后复检运行模式和 TUN 状态 if !matches!(*self.get_running_mode(), RunningMode::Sidecar) || !Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false) + || Config::tun_suppressed_for_session() { return HandoffOutcome::Done; } @@ -290,37 +719,542 @@ impl CoreManager { Type::Core, "service became ready; handing off from sidecar to service" ); - self.stop_core_by_sidecar(); + if let Err(error) = self.stop_sidecar_after_proxy_clear().await { + logging!( + error, + Type::Core, + "failed to clear proxy before sidecar handoff: {error:#}" + ); + return HandoffOutcome::Failed; + } - match self.start_core_by_service().await { - Ok(()) => { - logging!(info, Type::Core, "handoff to service mode succeeded"); - HandoffOutcome::Done - } - Err(e) => { + let service_result = run_ready_core_start_transition( + || self.start_core_by_service(), + || { + matches!(*self.get_running_mode(), RunningMode::Service) + && self.current_core_readiness_generation().is_some() + }, + || self.apply_proxy_after_start(), + ) + .await; + let Err(service_error) = service_result else { + logging!(info, Type::Core, "handoff to service mode succeeded"); + return HandoffOutcome::Done; + }; + + logging!(error, Type::Core, "handoff to service failed: {service_error:#}"); + if let Err(cleanup_error) = self.controlled_stop_core_inner().await { + logging!( + error, + Type::Core, + "failed to stop service before sidecar fallback: {cleanup_error:#}" + ); + return HandoffOutcome::Failed; + } + + let sidecar_result = run_ready_core_start_transition( + || self.start_core_by_sidecar(), + || { + matches!(*self.get_running_mode(), RunningMode::Sidecar) + && self.current_core_readiness_generation().is_some() + }, + || self.apply_proxy_after_start(), + ) + .await; + if let Err(sidecar_error) = sidecar_result { + logging!(error, Type::Core, "sidecar fallback failed: {sidecar_error:#}"); + if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await { logging!( error, Type::Core, - "handoff to service failed: {}; restarting sidecar", - e + "failed to clear proxy before sidecar rollback: {cleanup_error:#}" ); - if let Err(e2) = self.start_core_by_sidecar().await { - logging!( - error, - Type::Core, - "failed to restart sidecar after handoff failure: {}", - e2 - ); - } - HandoffOutcome::Failed } + } else { + logging!( + info, + Type::Core, + "sidecar fallback became ready and restored proxy state" + ); } + HandoffOutcome::Failed } } #[cfg(test)] mod tests { - use super::should_wait_for_service; + use super::{ + CoreManager, ProxyRestoreExpectation, StartupDecision, can_allow_sidecar_for_session, + run_controlled_stop_transition, run_core_replacement_transition, run_core_start_transition, + run_ready_core_start_transition, run_service_config_replacement_transition, run_sidecar_termination_transition, + run_uninstall_transition, should_wait_for_service, startup_decision, + }; + use crate::core::{manager::RunningMode, service::ServiceStatus}; + use parking_lot::Mutex; + use std::{ + future, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + task::Poll, + }; + use tokio::sync::{Barrier, Mutex as AsyncMutex}; + + struct FakeGuardCoordinator { + generation: AtomicU64, + operation_lock: AsyncMutex<()>, + } + + impl FakeGuardCoordinator { + const fn new() -> Self { + Self { + generation: AtomicU64::new(1), + operation_lock: AsyncMutex::const_new(()), + } + } + + async fn run_guard_request(&self, captured_generation: u64, calls: &Mutex>) -> bool { + let _operation = self.operation_lock.lock().await; + if self.generation.load(Ordering::Acquire) != captured_generation { + return false; + } + calls.lock().push("guard-reapply"); + true + } + + async fn stop_guard(&self, calls: &Mutex>) { + self.generation.fetch_add(1, Ordering::AcqRel); + let _operation = self.operation_lock.lock().await; + calls.lock().push("guard-stopped"); + } + } + + fn transition_step( + calls: &Arc>>, + step: &'static str, + fail_at: Option<&'static str>, + ) -> future::Ready> { + calls.lock().push(step); + future::ready(if fail_at == Some(step) { + Err(anyhow::anyhow!("{step} failed")) + } else { + Ok(()) + }) + } + + #[tokio::test] + async fn uninstall_transition_is_ordered_and_short_circuits_at_every_failure() { + let steps = ["uninstall", "prepare-sidecar", "start-sidecar"]; + + for fail_at in std::iter::once(None).chain(steps.map(Some)) { + let calls = Arc::new(Mutex::new(Vec::new())); + let result = run_uninstall_transition( + || transition_step(&calls, steps[0], fail_at), + || transition_step(&calls, steps[1], fail_at), + || transition_step(&calls, steps[2], fail_at), + ) + .await; + + let expected_len = fail_at + .and_then(|failed| steps.iter().position(|step| *step == failed).map(|index| index + 1)) + .unwrap_or(steps.len()); + assert_eq!(&*calls.lock(), &steps[..expected_len]); + assert_eq!(result.is_err(), fail_at.is_some()); + } + } + + #[tokio::test] + async fn controlled_sidecar_stop_clears_proxy_before_stopping() { + let calls = Arc::new(Mutex::new(Vec::new())); + + let result = run_controlled_stop_transition( + true, + RunningMode::Sidecar, + || future::ready(()), + || transition_step(&calls, "proxy_clear", None), + || transition_step(&calls, "core_stop", None), + ) + .await; + + assert!(result.is_ok()); + assert_eq!(&*calls.lock(), &["proxy_clear", "core_stop"]); + } + + #[tokio::test] + async fn controlled_macos_service_stop_drains_guard_before_one_helper_transaction() { + let calls = Arc::new(Mutex::new(Vec::new())); + + let result = run_controlled_stop_transition( + true, + RunningMode::Service, + || { + calls.lock().push("guard-stopped"); + future::ready(()) + }, + || transition_step(&calls, "unexpected-proxy-clear", None), + || transition_step(&calls, "service_stop_with_proxy_clear", None), + ) + .await; + + assert!(result.is_ok()); + assert_eq!(&*calls.lock(), &["guard-stopped", "service_stop_with_proxy_clear"]); + } + + #[tokio::test] + async fn failed_macos_service_stop_cannot_be_followed_by_a_stale_guard_write() { + let coordinator = Arc::new(FakeGuardCoordinator::new()); + let calls = Arc::new(Mutex::new(Vec::new())); + let guard_started = Arc::new(Barrier::new(2)); + let release_guard = Arc::new(Barrier::new(2)); + let captured_generation = coordinator.generation.load(Ordering::Acquire); + + let in_flight_guard = { + let coordinator = Arc::clone(&coordinator); + let calls = Arc::clone(&calls); + let guard_started = Arc::clone(&guard_started); + let release_guard = Arc::clone(&release_guard); + tokio::spawn(async move { + let _operation = coordinator.operation_lock.lock().await; + calls.lock().push("guard-started"); + guard_started.wait().await; + release_guard.wait().await; + calls.lock().push("guard-finished"); + }) + }; + guard_started.wait().await; + + let mut stop = Box::pin(run_controlled_stop_transition( + true, + RunningMode::Service, + || async { + coordinator.stop_guard(&calls).await; + }, + || transition_step(&calls, "unexpected-proxy-clear", None), + || transition_step(&calls, "service-stop-failed", Some("service-stop-failed")), + )); + assert!(matches!(futures::poll!(stop.as_mut()), Poll::Pending)); + release_guard.wait().await; + + assert!(in_flight_guard.await.is_ok()); + assert!(stop.await.is_err()); + assert!(!coordinator.run_guard_request(captured_generation, &calls).await); + assert_eq!( + &*calls.lock(), + &[ + "guard-started", + "guard-finished", + "guard-stopped", + "service-stop-failed" + ] + ); + } + + #[tokio::test] + async fn controlled_sidecar_stop_aborts_when_proxy_clear_fails() { + let calls = Arc::new(Mutex::new(Vec::new())); + + let result = run_controlled_stop_transition( + true, + RunningMode::Sidecar, + || future::ready(()), + || transition_step(&calls, "proxy_clear", Some("proxy_clear")), + || transition_step(&calls, "core_stop", None), + ) + .await; + + assert!(result.is_err()); + assert_eq!(&*calls.lock(), &["proxy_clear"]); + } + + #[tokio::test] + async fn failed_sidecar_transition_rollback_surfaces_clear_failure_without_terminating() { + let calls = Arc::new(Mutex::new(Vec::new())); + let sidecar_alive = AtomicBool::new(true); + + let result = run_sidecar_termination_transition( + || transition_step(&calls, "rollback-proxy-clear", Some("rollback-proxy-clear")), + || { + sidecar_alive.store(false, Ordering::Release); + calls.lock().push("rollback-sidecar-stop"); + }, + ) + .await; + + assert!(matches!( + result, + Err(error) if error.to_string().contains("rollback-proxy-clear failed") + )); + assert!(sidecar_alive.load(Ordering::Acquire)); + assert_eq!(&*calls.lock(), &["rollback-proxy-clear"]); + } + + #[tokio::test] + async fn handoff_sidecar_cleanup_is_clear_gated_and_ordered() { + for fail_at in [None, Some("handoff-proxy-clear")] { + let calls = Arc::new(Mutex::new(Vec::new())); + let sidecar_alive = AtomicBool::new(true); + + let result = run_sidecar_termination_transition( + || transition_step(&calls, "handoff-proxy-clear", fail_at), + || { + sidecar_alive.store(false, Ordering::Release); + calls.lock().push("handoff-sidecar-stop"); + }, + ) + .await; + + if fail_at.is_some() { + assert!(result.is_err()); + assert!(sidecar_alive.load(Ordering::Acquire)); + assert_eq!(&*calls.lock(), &["handoff-proxy-clear"]); + } else { + assert!(result.is_ok()); + assert!(!sidecar_alive.load(Ordering::Acquire)); + assert_eq!(&*calls.lock(), &["handoff-proxy-clear", "handoff-sidecar-stop"]); + } + } + } + + #[tokio::test] + async fn handoff_chosen_core_applies_proxy_only_after_required_readiness() { + let calls = Arc::new(Mutex::new(Vec::new())); + + let result = run_ready_core_start_transition( + || transition_step(&calls, "core-start", None), + || { + calls.lock().push("core-ready"); + true + }, + || transition_step(&calls, "proxy-apply", None), + ) + .await; + + assert!(result.is_ok()); + assert_eq!(&*calls.lock(), &["core-start", "core-ready", "proxy-apply"]); + } + + #[tokio::test] + async fn handoff_chosen_core_without_readiness_never_applies_proxy() { + let calls = Arc::new(Mutex::new(Vec::new())); + + let result = run_ready_core_start_transition( + || transition_step(&calls, "core-start", None), + || { + calls.lock().push("core-not-ready"); + false + }, + || transition_step(&calls, "unexpected-proxy-apply", None), + ) + .await; + + assert!(result.is_err()); + assert_eq!(&*calls.lock(), &["core-start", "core-not-ready"]); + } + + #[tokio::test] + async fn handoff_cleanup_failure_aborts_fallback_and_leaves_service_alive() { + let calls = Arc::new(Mutex::new(Vec::new())); + let service_alive = AtomicBool::new(true); + + let result = run_controlled_stop_transition( + false, + RunningMode::Service, + || future::ready(()), + || transition_step(&calls, "service-proxy-clear", Some("service-proxy-clear")), + || { + service_alive.store(false, Ordering::Release); + transition_step(&calls, "service-stop", None) + }, + ) + .await; + + assert!(result.is_err()); + assert!(service_alive.load(Ordering::Acquire)); + assert_eq!(&*calls.lock(), &["service-proxy-clear"]); + } + + #[tokio::test] + async fn controlled_start_applies_proxy_only_after_core_is_ready() { + let calls = Arc::new(Mutex::new(Vec::new())); + + let result = run_core_start_transition( + || transition_step(&calls, "core_start", None), + || true, + || transition_step(&calls, "proxy_apply", None), + ) + .await; + + assert!(result.is_ok()); + assert_eq!(&*calls.lock(), &["core_start", "proxy_apply"]); + } + + #[tokio::test] + async fn controlled_start_does_not_apply_proxy_without_a_ready_core() { + let calls = Arc::new(Mutex::new(Vec::new())); + + let result = run_core_start_transition( + || transition_step(&calls, "core_start", None), + || false, + || transition_step(&calls, "proxy_apply", None), + ) + .await; + + assert!(result.is_ok()); + assert_eq!(&*calls.lock(), &["core_start"]); + } + + #[tokio::test] + async fn restart_start_failure_keeps_proxy_direct_guard_stopped_and_pac_unavailable() { + let proxy_is_direct = AtomicBool::new(false); + let guard_stopped = AtomicBool::new(false); + let pac_available = AtomicBool::new(true); + let restored_new_core = AtomicBool::new(false); + + let result = run_core_replacement_transition( + || { + proxy_is_direct.store(true, Ordering::Release); + guard_stopped.store(true, Ordering::Release); + pac_available.store(false, Ordering::Release); + future::ready(Ok(())) + }, + || future::ready(Err(anyhow::anyhow!("new core failed to start"))), + || { + restored_new_core.store(true, Ordering::Release); + future::ready(Ok(())) + }, + ) + .await; + + assert!(result.is_err()); + assert!(proxy_is_direct.load(Ordering::Acquire)); + assert!(guard_stopped.load(Ordering::Acquire)); + assert!(!pac_available.load(Ordering::Acquire)); + assert!(!restored_new_core.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn service_config_start_failure_uses_the_same_fail_closed_replacement_order() { + let calls = Arc::new(Mutex::new(Vec::new())); + + let result = run_core_replacement_transition( + || transition_step(&calls, "controlled-stop", None), + || transition_step(&calls, "start-service-config", Some("start-service-config")), + || transition_step(&calls, "restore-proxy", None), + ) + .await; + + assert!(result.is_err()); + assert_eq!(&*calls.lock(), &["controlled-stop", "start-service-config"]); + } + + #[tokio::test] + async fn service_config_replacement_is_controlled_and_generation_safe_on_every_platform() { + for is_macos in [false, true] { + let calls = Arc::new(Mutex::new(Vec::new())); + let manager = CoreManager::default(); + let old_readiness_generation = manager.mark_core_ready(); + manager.set_running_mode(RunningMode::Service); + let owner_monitor_generation = AtomicU64::new(1); + let old_owner_monitor_generation = owner_monitor_generation.load(Ordering::Acquire); + + let result = run_service_config_replacement_transition( + is_macos, + || { + calls.lock().push("guard-stopped"); + future::ready(()) + }, + || transition_step(&calls, "proxy-clear", None), + || { + calls.lock().push("helper-stop"); + owner_monitor_generation.fetch_add(1, Ordering::AcqRel); + manager.set_running_mode(RunningMode::NotRunning); + future::ready(Ok(())) + }, + || { + calls.lock().push("service-start"); + owner_monitor_generation.fetch_add(1, Ordering::AcqRel); + manager.mark_core_ready(); + manager.set_running_mode(RunningMode::Service); + if owner_monitor_generation + .compare_exchange( + old_owner_monitor_generation, + old_owner_monitor_generation.wrapping_add(1), + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + calls.lock().push("old-monitor-invalidated-readiness"); + manager.invalidate_core_readiness(); + } else { + calls.lock().push("old-monitor-rejected"); + } + future::ready(Ok(())) + }, + || { + calls.lock().push("readiness-checked"); + matches!(*manager.get_running_mode(), RunningMode::Service) + && manager.current_core_readiness_generation().is_some() + && manager.current_core_readiness_generation() != Some(old_readiness_generation) + }, + || transition_step(&calls, "proxy-apply", None), + ) + .await; + + assert!(result.is_ok()); + let expected_preparation = if is_macos { "guard-stopped" } else { "proxy-clear" }; + assert_eq!( + &*calls.lock(), + &[ + expected_preparation, + "helper-stop", + "service-start", + "old-monitor-rejected", + "readiness-checked", + "proxy-apply", + ] + ); + } + } + + #[test] + fn startup_waits_when_a_service_requirement_cannot_be_satisfied() { + assert_eq!(startup_decision(&ServiceStatus::Ready, true), StartupDecision::Service); + assert_eq!( + startup_decision(&ServiceStatus::NotInstalled, true), + StartupDecision::Wait + ); + assert_eq!( + startup_decision(&ServiceStatus::NotInstalled, false), + StartupDecision::Sidecar + ); + assert_eq!( + startup_decision(&ServiceStatus::SidecarAllowed, true), + StartupDecision::Sidecar + ); + assert_eq!( + startup_decision(&ServiceStatus::NeedsReinstall, true), + StartupDecision::Wait + ); + assert_eq!( + startup_decision(&ServiceStatus::Unavailable("broken".into()), true), + StartupDecision::Wait + ); + assert_eq!(startup_decision(&ServiceStatus::Checking, true), StartupDecision::Wait); + } + + #[test] + fn service_evidence_failures_never_silently_fall_back() { + assert_eq!( + startup_decision(&ServiceStatus::NeedsReinstall, false), + StartupDecision::Wait + ); + assert_eq!( + startup_decision(&ServiceStatus::Unavailable("missing".into()), false), + StartupDecision::Wait + ); + } #[test] fn service_wait_is_only_required_for_non_admin_tun() { @@ -329,4 +1263,71 @@ mod tests { assert!(!should_wait_for_service(true, true, false)); assert!(!should_wait_for_service(false, false, false)); } + + #[test] + fn sidecar_session_allowance_is_cross_platform_and_explicit() { + let allowed_statuses = [ + ServiceStatus::NotInstalled, + ServiceStatus::NeedsReinstall, + ServiceStatus::InstallRequired, + ServiceStatus::Unavailable("offline".into()), + ]; + for status in &allowed_statuses { + assert!(can_allow_sidecar_for_session(&RunningMode::NotRunning, status)); + assert!(!can_allow_sidecar_for_session(&RunningMode::Service, status)); + if !matches!(status, ServiceStatus::InstallRequired) { + assert!(!can_allow_sidecar_for_session(&RunningMode::Sidecar, status)); + } + } + + let rejected_statuses = [ + ServiceStatus::Checking, + ServiceStatus::Ready, + ServiceStatus::UninstallRequired, + ServiceStatus::ReinstallRequired, + ServiceStatus::ForceReinstallRequired, + ServiceStatus::SidecarAllowed, + ]; + for status in &rejected_statuses { + assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status)); + } + } + + #[test] + fn failed_service_install_can_continue_with_an_existing_sidecar() { + assert!(can_allow_sidecar_for_session( + &RunningMode::Sidecar, + &ServiceStatus::InstallRequired, + )); + } + + #[test] + fn proxy_restore_requires_the_same_ready_core_generation() { + let sidecar = + ProxyRestoreExpectation::capture(RunningMode::Sidecar, Some(20), 8).unwrap_or_else(|| unreachable!()); + assert!(sidecar.is_valid(&RunningMode::Sidecar, Some(20), 8)); + assert!(!sidecar.is_valid(&RunningMode::Sidecar, Some(21), 8)); + assert!(!sidecar.is_valid(&RunningMode::Sidecar, None, 8)); + assert!(ProxyRestoreExpectation::capture(RunningMode::Sidecar, None, 8).is_none()); + + let service = + ProxyRestoreExpectation::capture(RunningMode::Service, Some(20), 8).unwrap_or_else(|| unreachable!()); + assert!(service.is_valid(&RunningMode::Service, Some(20), 8)); + assert!(!service.is_valid(&RunningMode::Service, Some(20), 9)); + assert!(!service.is_valid(&RunningMode::NotRunning, Some(20), 8)); + assert!(ProxyRestoreExpectation::capture(RunningMode::Service, None, 8).is_none()); + assert!(ProxyRestoreExpectation::capture(RunningMode::NotRunning, Some(20), 8).is_none()); + } + + #[tokio::test] + async fn failed_start_rolls_back_even_from_service_mode() { + let manager = CoreManager::default(); + manager.set_running_mode(RunningMode::Service); + manager.rollback_failed_start().await; + assert_eq!(*manager.get_running_mode(), RunningMode::NotRunning); + + manager.set_running_mode(RunningMode::Sidecar); + manager.rollback_failed_start().await; + assert_eq!(*manager.get_running_mode(), RunningMode::NotRunning); + } } diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index 03f7e46f9..70a8b5022 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -10,7 +10,7 @@ use std::{ fmt, sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, }, time::Instant, }; @@ -22,7 +22,37 @@ use std::os::windows::io::OwnedHandle; pub(crate) static CLASH_LOGGER: Lazy> = Lazy::new(|| Arc::new(AsyncLogger::new())); -#[derive(Debug, serde::Serialize, PartialEq, Eq)] +const CORE_READINESS_ACTIVE_BIT: u64 = 1; +const CORE_READINESS_GENERATION_STEP: u64 = 1 << 1; + +const fn next_active_core_readiness_state(state: u64) -> u64 { + (state.wrapping_add(CORE_READINESS_GENERATION_STEP) & !CORE_READINESS_ACTIVE_BIT) | CORE_READINESS_ACTIVE_BIT +} + +const fn inactive_core_readiness_state(state: u64) -> u64 { + state.wrapping_add(CORE_READINESS_GENERATION_STEP) & !CORE_READINESS_ACTIVE_BIT +} + +const fn active_core_readiness_generation(state: u64) -> Option { + if state & CORE_READINESS_ACTIVE_BIT != 0 { + Some(state) + } else { + None + } +} + +fn claim_core_readiness_generation(state: &AtomicU64, captured_generation: u64) -> bool { + state + .compare_exchange( + captured_generation, + inactive_core_readiness_state(captured_generation), + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() +} + +#[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq)] pub enum RunningMode { Service, Sidecar, @@ -46,9 +76,10 @@ pub struct CoreManager { #[cfg(target_os = "windows")] job_handle: ArcSwapOption, config_update_in_progress: AtomicBool, + core_readiness_state: AtomicU64, // 串行化 start/stop/restart 和 sidecar→service 交接。 // 锁序固定为 config_update_in_progress → lifecycle_lock。 - lifecycle_lock: tokio::sync::Mutex<()>, + pub(crate) lifecycle_lock: tokio::sync::Mutex<()>, // sidecar→service 交接 watcher 单实例标志。 #[cfg(target_os = "windows")] handoff_watcher_running: AtomicBool, @@ -77,6 +108,7 @@ impl Default for CoreManager { #[cfg(target_os = "windows")] job_handle: ArcSwapOption::new(None), config_update_in_progress: AtomicBool::new(false), + core_readiness_state: AtomicU64::new(0), lifecycle_lock: tokio::sync::Mutex::new(()), #[cfg(target_os = "windows")] handoff_watcher_running: AtomicBool::new(false), @@ -101,11 +133,18 @@ impl CoreManager { .and_then(|arc| Arc::try_unwrap(arc).ok()) } + pub fn get_running_sidecar_pid(&self) -> Option { + self.state.load().child_sidecar.load_full().map(|child| child.pid()) + } + pub fn get_last_update(&self) -> Option> { self.last_update.load_full() } pub fn set_running_mode(&self, mode: RunningMode) { + if matches!(mode, RunningMode::NotRunning) { + self.invalidate_core_readiness(); + } let state = self.state.load(); state.running_mode.store(Arc::new(mode)); } @@ -115,6 +154,36 @@ impl CoreManager { state.child_sidecar.store(Some(Arc::new(child))); } + fn mark_core_ready(&self) -> u64 { + let mut current = self.core_readiness_state.load(Ordering::Acquire); + loop { + let next = next_active_core_readiness_state(current); + match self + .core_readiness_state + .compare_exchange(current, next, Ordering::AcqRel, Ordering::Acquire) + { + Ok(_) => return next, + Err(changed) => current = changed, + } + } + } + + fn current_core_readiness_generation(&self) -> Option { + active_core_readiness_generation(self.core_readiness_state.load(Ordering::Acquire)) + } + + pub(crate) fn invalidate_core_readiness(&self) { + let _ = self + .core_readiness_state + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + active_core_readiness_generation(current).map(inactive_core_readiness_state) + }); + } + + fn invalidate_core_readiness_if(&self, captured_generation: u64) -> bool { + claim_core_readiness_generation(&self.core_readiness_state, captured_generation) + } + pub fn set_last_update(&self, time: Instant) { self.last_update.store(Some(Arc::new(time))); } @@ -138,9 +207,9 @@ impl CoreManager { self.config_update_in_progress.store(false, Ordering::Release); } - pub async fn init(&self) -> Result<()> { + pub async fn init(&self) -> Result { self.start_core().await?; - Ok(()) + Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning)) } } diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index e952e891d..221780168 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -1,18 +1,54 @@ +#[cfg(test)] +use super::claim_core_readiness_generation; use super::{CoreManager, RunningMode}; use crate::{ AsyncHandler, - config::{Config, IClashTemp}, - core::{handle, logger::Logger, manager::CLASH_LOGGER, service}, + config::Config, + core::{handle, logger::Logger, manager::CLASH_LOGGER, proxy_control, service}, logging, - utils::dirs, + utils::{dirs, server}, }; -use anyhow::Result; +use anyhow::{Context as _, Result}; use clash_verge_logging::Type; use compact_str::CompactString; use log::Level; use scopeguard::defer; +use std::path::Path; +use tauri_plugin_mihomo::MihomoExt as _; use tauri_plugin_shell::ShellExt as _; +const SIDECAR_READINESS_ATTEMPTS: usize = 30; +const SIDECAR_READINESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100); +const SIDECAR_READINESS_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(400); + +async fn poll_sidecar_readiness( + max_attempts: usize, + retry_delay: std::time::Duration, + mut probe: F, +) -> Result<()> +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let mut last_error = None; + for attempt in 0..max_attempts { + match probe().await { + Ok(()) => return Ok(()), + Err(error) => last_error = Some(error), + } + if attempt + 1 < max_attempts { + tokio::time::sleep(retry_delay).await; + } + } + Err(last_error + .unwrap_or_else(|| anyhow::anyhow!("sidecar readiness was configured with no attempts")) + .context("Mihomo API did not become ready")) +} + +fn should_clear_terminated_sidecar(running_mode: &RunningMode, current_pid: Option, terminated_pid: u32) -> bool { + matches!(running_mode, RunningMode::Sidecar) && current_pid == Some(terminated_pid) +} + #[cfg(target_os = "windows")] use { std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}, @@ -39,33 +75,43 @@ impl CoreManager { pub(super) async fn start_core_by_sidecar(&self) -> Result<()> { logging!(info, Type::Core, "Starting core in sidecar mode"); + server::set_pac_available(false); + self.set_running_mode(RunningMode::NotRunning); + let sidecar_ipc = dirs::sidecar_ipc_path()?; + handle::Handle::app_handle() + .mihomo() + .write() + .await + .update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?; let config_file = Config::generate_file(crate::config::ConfigType::Run).await?; let app_handle = handle::Handle::app_handle(); let clash_core = Config::verge().await.latest_arc().get_valid_clash_core(); let config_dir = dirs::app_home_dir()?; #[cfg(unix)] - let previous_mask = unsafe { tauri_plugin_clash_verge_sysinfo::libc::umask(0o007) }; - let (mut rx, child) = app_handle - .shell() - .sidecar(clash_core.as_str())? - .args([ - "-d", - dirs::path_to_str(&config_dir)?, - "-f", - dirs::path_to_str(&config_file)?, - if cfg!(windows) { - "-ext-ctl-pipe" - } else { - "-ext-ctl-unix" - }, - &IClashTemp::guard_external_controller_ipc(), - ]) - .spawn()?; + let previous_mask = unsafe { tauri_plugin_clash_verge_sysinfo::libc::umask(0o077) }; + let command = app_handle.shell().sidecar(clash_core.as_str())?.args([ + "-d", + dirs::path_to_str(&config_dir)?, + "-f", + dirs::path_to_str(&config_file)?, + if cfg!(windows) { + "-ext-ctl-pipe" + } else { + "-ext-ctl-unix" + }, + dirs::path_to_str(&sidecar_ipc)?, + ]); + #[cfg(windows)] + let command = command.env( + "LISTEN_NAMEDPIPE_SDDL", + crate::core::owner_identity::current_user_pipe_sddl()?, + ); + let (mut rx, child) = command.spawn()?; #[cfg(target_os = "windows")] - { - let job = match create_and_assign_sidecar_job(child.pid()) { + let job = { + match create_and_assign_sidecar_job(child.pid()) { Ok(job) => job, Err(job_error) => { let pid = child.pid(); @@ -81,9 +127,8 @@ impl CoreManager { logging!(error, Type::Core, "Failed to start sidecar: {error:#}"); return Err(error); } - }; - self.set_job_handle(Some(job)); - } + } + }; #[cfg(unix)] unsafe { @@ -93,10 +138,36 @@ impl CoreManager { let pid = child.pid(); logging!(trace, Type::Core, "Sidecar started with PID: {}", pid); - self.set_running_child_sidecar(child); - self.set_running_mode(RunningMode::Sidecar); + let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async { + tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async { + handle::Handle::mihomo().await.get_version().await + }) + .await + .context("Mihomo readiness probe timed out")??; + Ok(()) + }) + .await; + if let Err(readiness_error) = readiness { + proxy_control::stop_guard().await; + self.invalidate_core_readiness(); + self.set_running_mode(RunningMode::NotRunning); + server::set_pac_available(false); + return match child.kill() { + Ok(()) => Err(readiness_error), + Err(kill_error) => Err(anyhow::anyhow!( + "{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}" + )), + }; + } - AsyncHandler::spawn(|| async move { + #[cfg(target_os = "windows")] + self.set_job_handle(Some(job)); + self.set_running_child_sidecar(child); + let core_readiness_generation = self.mark_core_ready(); + self.set_running_mode(RunningMode::Sidecar); + server::set_pac_available(true); + + AsyncHandler::spawn(move || async move { while let Some(event) = rx.recv().await { match event { tauri_plugin_shell::process::CommandEvent::Stdout(line) @@ -106,6 +177,8 @@ impl CoreManager { CLASH_LOGGER.append_log(message).await; } tauri_plugin_shell::process::CommandEvent::Terminated(term) => { + let manager = Self::global(); + let _ = manager.invalidate_core_readiness_if(core_readiness_generation); let message = if let Some(code) = term.code { CompactString::from(format!("Process terminated with code: {}", code)) } else if let Some(signal) = term.signal { @@ -115,6 +188,7 @@ impl CoreManager { }; Logger::global().writer_sidecar_log(Level::Info, &message); CLASH_LOGGER.clear_logs().await; + manager.clear_terminated_sidecar(pid).await; break; } _ => {} @@ -125,9 +199,12 @@ impl CoreManager { Ok(()) } - pub(super) fn stop_core_by_sidecar(&self) { + /// Terminates the sidecar after its caller has successfully cleared the + /// system proxy. + pub(super) fn stop_core_by_sidecar_unprepared(&self) { logging!(info, Type::Core, "Stopping sidecar"); defer! { + server::set_pac_available(false); self.set_running_mode(RunningMode::NotRunning); } if let Some(child) = self.take_child_sidecar() { @@ -159,16 +236,28 @@ impl CoreManager { pub(super) async fn start_core_by_service(&self) -> Result<()> { logging!(info, Type::Core, "Starting core in service mode"); + server::set_pac_available(false); + let service_ipc = dirs::ipc_path()?; let config_file = Config::generate_file(crate::config::ConfigType::Run).await?; + handle::Handle::app_handle() + .mihomo() + .write() + .await + .update_socket_path(dirs::path_to_str(&service_ipc)?.to_owned())?; + self.start_core_by_service_with_config(&config_file).await + } + + pub(super) async fn start_core_by_service_with_config(&self, config_file: &Path) -> Result<()> { // 交接时等待 sidecar 释放 ext-controller 通道。 #[cfg(target_os = "windows")] { use crate::constants::timing; let mut last_err = None; for attempt in 0..timing::SERVICE_START_RETRIES { - match service::run_core_by_service(&config_file).await { + match service::run_core_by_service(config_file).await { Ok(()) => { + self.mark_core_ready(); self.set_running_mode(RunningMode::Service); return Ok(()); } @@ -191,7 +280,8 @@ impl CoreManager { #[cfg(not(target_os = "windows"))] { - service::run_core_by_service(&config_file).await?; + service::run_core_by_service(config_file).await?; + self.mark_core_ready(); self.set_running_mode(RunningMode::Service); Ok(()) } @@ -199,12 +289,100 @@ impl CoreManager { pub(super) async fn stop_core_by_service(&self) -> Result<()> { logging!(info, Type::Core, "Stopping service"); - defer! { - self.set_running_mode(RunningMode::NotRunning); - } service::stop_core_by_service().await?; + server::set_pac_available(false); + self.set_running_mode(RunningMode::NotRunning); Ok(()) } + + async fn clear_terminated_sidecar(&self, terminated_pid: u32) { + let _life = self.lifecycle_lock.lock().await; + if !should_clear_terminated_sidecar(&self.get_running_mode(), self.get_running_sidecar_pid(), terminated_pid) { + return; + } + + let _ = self.take_child_sidecar(); + #[cfg(target_os = "windows")] + self.set_job_handle(None); + proxy_control::stop_guard().await; + server::set_pac_available(false); + self.invalidate_core_readiness(); + self.set_running_mode(RunningMode::NotRunning); + self.after_core_process(); + } +} + +#[cfg(test)] +mod readiness_tests { + use super::{claim_core_readiness_generation, poll_sidecar_readiness, should_clear_terminated_sidecar}; + use crate::core::manager::{CoreManager, RunningMode}; + use std::{ + sync::{ + Arc, + atomic::{AtomicU64, AtomicUsize, Ordering}, + }, + time::Duration, + }; + + #[tokio::test] + async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> { + let attempts = Arc::new(AtomicUsize::new(0)); + let probe_attempts = Arc::clone(&attempts); + poll_sidecar_readiness(3, Duration::ZERO, move || { + let attempt = probe_attempts.fetch_add(1, Ordering::SeqCst) + 1; + async move { + if attempt == 3 { + Ok(()) + } else { + Err(anyhow::anyhow!("not ready")) + } + } + }) + .await?; + assert_eq!(attempts.load(Ordering::SeqCst), 3); + + let failed_attempts = Arc::new(AtomicUsize::new(0)); + let probe_attempts = Arc::clone(&failed_attempts); + assert!( + poll_sidecar_readiness(3, Duration::ZERO, move || { + probe_attempts.fetch_add(1, Ordering::SeqCst); + async { Err(anyhow::anyhow!("still unavailable")) } + }) + .await + .is_err() + ); + assert_eq!(failed_attempts.load(Ordering::SeqCst), 3); + Ok(()) + } + + #[test] + fn only_the_current_sidecar_termination_clears_local_state() { + assert!(should_clear_terminated_sidecar(&RunningMode::Sidecar, Some(42), 42)); + assert!(!should_clear_terminated_sidecar(&RunningMode::Sidecar, Some(43), 42)); + assert!(!should_clear_terminated_sidecar(&RunningMode::Service, Some(42), 42)); + assert!(!should_clear_terminated_sidecar(&RunningMode::NotRunning, None, 42)); + } + + #[test] + fn core_readiness_generation_can_only_be_claimed_once() { + let generation = AtomicU64::new(7); + + assert!(claim_core_readiness_generation(&generation, 7)); + assert_eq!(generation.load(Ordering::Acquire), 8); + assert!(!claim_core_readiness_generation(&generation, 7)); + } + + #[test] + fn invalidated_core_readiness_cannot_be_recaptured_from_stale_mode() { + let manager = CoreManager::default(); + manager.mark_core_ready(); + manager.set_running_mode(RunningMode::Service); + + manager.invalidate_core_readiness(); + + assert_eq!(*manager.get_running_mode(), RunningMode::Service); + assert_eq!(manager.current_core_readiness_generation(), None); + } } #[cfg(target_os = "windows")] diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index a776f59f9..287ca8f25 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -5,7 +5,10 @@ pub mod hotkey; pub mod logger; pub mod manager; mod notification; +pub(crate) mod owner_identity; +pub mod proxy_control; pub mod proxy_view; +mod runtime_bundle; pub mod service; pub mod sysopt; pub mod timer; diff --git a/src-tauri/src/core/owner_identity.rs b/src-tauri/src/core/owner_identity.rs new file mode 100644 index 000000000..d0b68c6fa --- /dev/null +++ b/src-tauri/src/core/owner_identity.rs @@ -0,0 +1,575 @@ +use crate::utils::dirs; +use anyhow::{Context as _, Result}; +use clash_verge_service_ipc::{OwnerCredentials, OwnerIdentity}; +use std::path::Path; + +pub(crate) fn current_owner_credentials() -> Result { + current_owner_credentials_for_root(&dirs::app_home_dir()?) +} + +#[allow(clippy::unnecessary_wraps)] // Windows SID discovery is fallible; Unix keeps the shared API. +pub(crate) fn current_owner_identity() -> Result { + #[cfg(unix)] + return Ok(OwnerIdentity::Unix { + uid: unsafe { libc::geteuid() }, + gid: unsafe { libc::getegid() }, + }); + + #[cfg(windows)] + { + windows_current_identity() + } +} + +pub(crate) fn current_owner_credentials_for_root(app_root: &Path) -> Result { + let app_data_root = std::fs::canonicalize(app_root) + .with_context(|| format!("failed to canonicalize application data root {app_root:?}"))?; + + #[cfg(unix)] + let (identity, token) = (current_owner_identity()?, None); + + #[cfg(windows)] + let (identity, token) = windows_owner_credentials(&app_data_root)?; + + Ok(OwnerCredentials { + identity, + app_data_dir: app_data_root.to_string_lossy().into_owned(), + token, + }) +} + +#[cfg(windows)] +fn windows_owner_credentials(app_data_root: &Path) -> Result<(OwnerIdentity, Option)> { + let sid = windows_owner::current_sid()?; + let token = windows_owner::load_or_create_token(app_data_root, &sid)?; + Ok((OwnerIdentity::Windows { sid }, Some(token))) +} + +#[cfg(windows)] +fn windows_current_identity() -> Result { + Ok(OwnerIdentity::Windows { + sid: windows_owner::current_sid()?, + }) +} + +#[cfg(windows)] +pub(crate) fn create_private_current_user_file(path: &Path) -> Result { + windows_owner::create_private_file(path) +} + +#[cfg(windows)] +pub(crate) fn open_private_current_user_file(path: &Path) -> Result { + windows_owner::open_private_file(path) +} + +#[cfg(windows)] +pub(crate) fn open_or_create_private_current_user_file(path: &Path) -> Result { + windows_owner::open_or_create_private_file(path) +} + +#[cfg(windows)] +pub(crate) fn current_user_pipe_sddl() -> Result { + let sid = windows_owner::current_sid()?; + Ok(format!("D:P(A;;GA;;;{sid})(A;;GA;;;SY)(A;;GA;;;BA)")) +} + +#[cfg(windows)] +mod windows_owner { + use anyhow::{Context as _, Result, bail}; + use clash_verge_service_ipc::OWNER_TOKEN_FILE_NAME; + use std::ffi::c_void; + use std::io::{Read as _, Write as _}; + use std::os::windows::ffi::OsStrExt as _; + use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _}; + use std::path::Path; + use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_FILE_EXISTS, GENERIC_READ, GENERIC_WRITE, GetLastError, INVALID_HANDLE_VALUE, LocalFree, + }; + use windows_sys::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, SDDL_REVISION_1, + SE_FILE_OBJECT, SetSecurityInfo, + }; + use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACL, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetSecurityDescriptorControl, + GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, GetTokenInformation, IsValidSid, IsWellKnownSid, + OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED, + SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid, + }; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, CREATE_NEW, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY, + FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_DISK, GetFileInformationByHandle, GetFileType, + OPEN_EXISTING, READ_CONTROL, WRITE_DAC, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + const TOKEN_BYTES: usize = 32; + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + + pub(super) fn current_sid() -> Result { + let mut token = std::ptr::null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(std::io::Error::last_os_error()).context("failed to open process token"); + } + let token = OwnedHandle(token); + + let mut required = 0_u32; + unsafe { GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut required) }; + if required == 0 { + return Err(std::io::Error::last_os_error()).context("failed to size process SID buffer"); + } + let words = (required as usize).div_ceil(std::mem::size_of::()); + let mut buffer = vec![0_usize; words]; + if unsafe { GetTokenInformation(token.0, TokenUser, buffer.as_mut_ptr().cast(), required, &mut required) } == 0 + { + return Err(std::io::Error::last_os_error()).context("failed to read process SID"); + } + let token_user = unsafe { &*buffer.as_ptr().cast::() }; + sid_to_string(token_user.User.Sid) + } + + pub(super) fn load_or_create_token(app_data_root: &Path, sid: &str) -> Result { + let token_path = app_data_root.join(OWNER_TOKEN_FILE_NAME); + let descriptor = LocalSecurityDescriptor::from_sid(sid)?; + let attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + let wide = wide_path(&token_path)?; + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + &attributes, + CREATE_NEW, + FILE_ATTRIBUTE_HIDDEN | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + + if handle != INVALID_HANDLE_VALUE { + let mut file = unsafe { std::fs::File::from_raw_handle(handle) }; + let mut token = [0_u8; TOKEN_BYTES]; + getrandom::fill(&mut token).context("failed to generate owner token")?; + file.write_all(&token).context("failed to write owner token")?; + file.sync_all().context("failed to flush owner token")?; + return Ok(encode_token(&token)); + } + if unsafe { GetLastError() } != ERROR_FILE_EXISTS { + return Err(std::io::Error::last_os_error()).context("failed to create owner token"); + } + + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + GENERIC_READ | READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()).context("failed to open owner token"); + } + let mut file = unsafe { std::fs::File::from_raw_handle(handle) }; + validate_token_file(&file, descriptor.owner()?)?; + descriptor.apply_dacl(file.as_raw_handle())?; + + let mut token = [0_u8; TOKEN_BYTES]; + file.read_exact(&mut token).context("failed to read owner token")?; + Ok(encode_token(&token)) + } + + pub(super) fn create_private_file(path: &Path) -> Result { + let sid = current_sid()?; + let descriptor = LocalSecurityDescriptor::from_sid(&sid)?; + create_file(path, &descriptor) + } + + pub(super) fn open_private_file(path: &Path) -> Result { + let sid = current_sid()?; + let descriptor = LocalSecurityDescriptor::from_sid(&sid)?; + let file = open_file(path)?; + validate_private_file(&file, descriptor.owner()?)?; + Ok(file) + } + + pub(super) fn open_or_create_private_file(path: &Path) -> Result { + let sid = current_sid()?; + let descriptor = LocalSecurityDescriptor::from_sid(&sid)?; + match create_file(path, &descriptor) { + Ok(file) => Ok(file), + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.raw_os_error() == Some(ERROR_FILE_EXISTS as i32)) => + { + let file = open_file(path)?; + validate_private_file(&file, descriptor.owner()?)?; + Ok(file) + } + Err(error) => Err(error), + } + } + + fn create_file(path: &Path, descriptor: &LocalSecurityDescriptor) -> Result { + let attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + let wide = wide_path(path)?; + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + &attributes, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()).context("failed to create private current-user file"); + } + let file = unsafe { std::fs::File::from_raw_handle(handle) }; + validate_private_file(&file, descriptor.owner()?)?; + Ok(file) + } + + fn open_file(path: &Path) -> Result { + let wide = wide_path(path)?; + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()).context("failed to open private current-user file"); + } + Ok(unsafe { std::fs::File::from_raw_handle(handle) }) + } + + fn validate_private_file(file: &std::fs::File, expected_owner: PSID) -> Result<()> { + let handle = file.as_raw_handle(); + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 { + return Err(std::io::Error::last_os_error()).context("failed to inspect private file metadata"); + } + if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0 + || unsafe { GetFileType(handle) } != FILE_TYPE_DISK + { + bail!("current-user file is not an ordinary file"); + } + + let mut owner = std::ptr::null_mut(); + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut security = std::ptr::null_mut(); + let status = unsafe { + GetSecurityInfo( + handle, + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + std::ptr::null_mut(), + &mut dacl, + std::ptr::null_mut(), + &mut security, + ) + }; + if status != 0 || security.is_null() { + bail!("failed to inspect private file security: Windows error {status}"); + } + let security = LocalSecurityDescriptor(security); + if owner.is_null() || unsafe { EqualSid(owner, expected_owner) } == 0 || dacl.is_null() { + bail!("current-user file owner or DACL is invalid"); + } + let mut control = 0_u16; + let mut revision = 0_u32; + if unsafe { GetSecurityDescriptorControl(security.0, &mut control, &mut revision) } == 0 + || control & SE_DACL_PROTECTED == 0 + { + bail!("current-user file DACL is not protected"); + } + + let mut owner_ace = false; + let mut system_ace = false; + let mut administrators_ace = false; + for index in 0..unsafe { (*dacl).AceCount } as u32 { + let mut ace = std::ptr::null_mut(); + if unsafe { GetAce(dacl, index, &mut ace) } == 0 || ace.is_null() { + bail!("current-user file DACL could not be inspected"); + } + let allowed = unsafe { &*ace.cast::() }; + if allowed.Header.AceType != ACCESS_ALLOWED_ACE_TYPE || allowed.Mask & FILE_ALL_ACCESS != FILE_ALL_ACCESS { + bail!("current-user file DACL contains an unexpected ACE"); + } + let ace_sid = std::ptr::addr_of!(allowed.SidStart).cast_mut().cast::(); + if unsafe { IsValidSid(ace_sid) } == 0 { + bail!("current-user file DACL contains an invalid SID"); + } + if unsafe { EqualSid(ace_sid, expected_owner) } != 0 { + owner_ace = true; + } else if unsafe { IsWellKnownSid(ace_sid, WinLocalSystemSid) } != 0 { + system_ace = true; + } else if unsafe { IsWellKnownSid(ace_sid, WinBuiltinAdministratorsSid) } != 0 { + administrators_ace = true; + } else { + bail!("current-user file DACL grants access to another SID"); + } + } + if unsafe { (*dacl).AceCount } != 3 || !owner_ace || !system_ace || !administrators_ace { + bail!("current-user file DACL is missing a required principal"); + } + Ok(()) + } + + fn validate_token_file(file: &std::fs::File, expected_owner: PSID) -> Result<()> { + let handle = file.as_raw_handle(); + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 { + return Err(std::io::Error::last_os_error()).context("failed to inspect owner token metadata"); + } + if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0 + || unsafe { GetFileType(handle) } != FILE_TYPE_DISK + || information.nFileSizeHigh != 0 + || information.nFileSizeLow != TOKEN_BYTES as u32 + { + bail!("owner token is not an ordinary 32-byte file"); + } + + let mut owner = std::ptr::null_mut(); + let mut security = std::ptr::null_mut(); + let status = unsafe { + GetSecurityInfo( + handle, + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + &mut owner, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut security, + ) + }; + if status != 0 || security.is_null() { + bail!("failed to inspect owner token security: Windows error {status}"); + } + let security = LocalSecurityDescriptor(security); + if owner.is_null() || unsafe { EqualSid(owner, expected_owner) } == 0 { + bail!("owner token belongs to a different Windows user"); + } + drop(security); + Ok(()) + } + + fn sid_to_string(sid: PSID) -> Result { + let mut value = std::ptr::null_mut(); + if unsafe { ConvertSidToStringSidW(sid, &mut value) } == 0 || value.is_null() { + return Err(std::io::Error::last_os_error()).context("failed to format process SID"); + } + let value = LocalWideString(value); + let length = (0..).take_while(|index| unsafe { *value.0.add(*index) } != 0).count(); + String::from_utf16(unsafe { std::slice::from_raw_parts(value.0, length) }) + .context("process SID is not valid UTF-16") + } + + fn encode_token(token: &[u8; TOKEN_BYTES]) -> String { + let mut encoded = String::with_capacity(TOKEN_BYTES * 2); + for byte in token { + use std::fmt::Write as _; + let _ = write!(encoded, "{byte:02x}"); + } + encoded + } + + fn wide_path(path: &Path) -> Result> { + let mut wide: Vec = path.as_os_str().encode_wide().collect(); + if wide.contains(&0) { + bail!("owner token path contains NUL"); + } + wide.push(0); + Ok(wide) + } + + struct OwnedHandle(*mut c_void); + + impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE { + unsafe { CloseHandle(self.0) }; + } + } + } + + struct LocalWideString(*mut u16); + + impl Drop for LocalWideString { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { LocalFree(self.0.cast()) }; + } + } + } + + struct LocalSecurityDescriptor(PSECURITY_DESCRIPTOR); + + impl LocalSecurityDescriptor { + fn from_sid(sid: &str) -> Result { + let sddl = format!("O:{sid}D:P(A;;FA;;;{sid})(A;;FA;;;SY)(A;;FA;;;BA)"); + let mut wide: Vec = sddl.encode_utf16().collect(); + wide.push(0); + let mut descriptor = std::ptr::null_mut(); + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + } == 0 + || descriptor.is_null() + { + return Err(std::io::Error::last_os_error()).context("failed to build owner token security descriptor"); + } + Ok(Self(descriptor)) + } + + fn owner(&self) -> Result { + let mut owner = std::ptr::null_mut(); + let mut defaulted = 0; + if unsafe { GetSecurityDescriptorOwner(self.0, &mut owner, &mut defaulted) } == 0 || owner.is_null() { + return Err(std::io::Error::last_os_error()).context("failed to read token descriptor owner"); + } + Ok(owner) + } + + fn apply_dacl(&self, handle: *mut c_void) -> Result<()> { + let mut present = 0; + let mut defaulted = 0; + let mut dacl = std::ptr::null_mut(); + if unsafe { GetSecurityDescriptorDacl(self.0, &mut present, &mut dacl, &mut defaulted) } == 0 + || present == 0 + || dacl.is_null() + { + return Err(std::io::Error::last_os_error()).context("failed to read token descriptor DACL"); + } + let status = unsafe { + SetSecurityInfo( + handle, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + dacl, + std::ptr::null(), + ) + }; + if status != 0 { + bail!("failed to restrict owner token DACL: Windows error {status}"); + } + Ok(()) + } + } + + impl Drop for LocalSecurityDescriptor { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { LocalFree(self.0) }; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::current_owner_credentials_for_root; + use clash_verge_service_ipc::OwnerIdentity; + + #[cfg(unix)] + #[test] + fn unix_credentials_come_from_process_and_have_no_token() -> anyhow::Result<()> { + let app_root = std::env::temp_dir(); + + let credentials = current_owner_credentials_for_root(&app_root)?; + + assert_eq!( + credentials.identity, + OwnerIdentity::Unix { + uid: unsafe { libc::geteuid() }, + gid: unsafe { libc::getegid() }, + } + ); + assert_eq!(credentials.token, None); + assert_eq!( + std::path::PathBuf::from(credentials.app_data_dir), + std::fs::canonicalize(app_root)? + ); + Ok(()) + } + + #[cfg(windows)] + #[test] + fn windows_credentials_use_stable_sid_and_private_token() -> anyhow::Result<()> { + let app_root = std::env::temp_dir().join(format!("cvr-owner-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&app_root); + std::fs::create_dir(&app_root)?; + + let first = current_owner_credentials_for_root(&app_root)?; + let second = current_owner_credentials_for_root(&app_root)?; + + let OwnerIdentity::Windows { sid } = &first.identity else { + anyhow::bail!("expected Windows owner identity"); + }; + assert!(sid.starts_with("S-1-5-")); + assert_eq!(first.identity, second.identity); + assert_eq!(first.token, second.token); + assert_eq!(first.token.as_deref().map(str::len), Some(64)); + + std::fs::remove_dir_all(app_root)?; + Ok(()) + } + + #[cfg(windows)] + #[test] + fn private_file_open_rejects_directories_and_reparse_points() -> anyhow::Result<()> { + use super::{create_private_current_user_file, open_private_current_user_file}; + + let root = std::env::temp_dir().join(format!("cvr-private-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir(&root)?; + + let private_file = root.join("record.json"); + drop(create_private_current_user_file(&private_file)?); + drop(open_private_current_user_file(&private_file)?); + + let directory = root.join("directory"); + std::fs::create_dir(&directory)?; + assert!(open_private_current_user_file(&directory).is_err()); + + let link = root.join("record-link.json"); + if let Err(error) = std::os::windows::fs::symlink_file(&private_file, &link) { + if error.raw_os_error() == Some(1314) { + std::fs::remove_dir_all(root)?; + return Ok(()); + } + return Err(error.into()); + } + assert!(open_private_current_user_file(&link).is_err()); + + std::fs::remove_file(link)?; + std::fs::remove_dir_all(root)?; + Ok(()) + } +} diff --git a/src-tauri/src/core/proxy_control.rs b/src-tauri/src/core/proxy_control.rs new file mode 100644 index 000000000..4a00b526d --- /dev/null +++ b/src-tauri/src/core/proxy_control.rs @@ -0,0 +1,503 @@ +use crate::{ + config::{Config, IVerge}, + core::{CoreManager, manager::RunningMode, service, sysopt::Sysopt}, + process::AsyncHandler, + utils::server, +}; +use anyhow::{Result, bail, ensure}; +use clash_verge_logging::{Type, logging}; +use clash_verge_service_ipc::{MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome}; +use std::{ + future::Future, + sync::atomic::{AtomicU64, Ordering}, + time::Duration, +}; +use tokio::sync::Mutex; + +const LOOPBACK_HOST: &str = "127.0.0.1"; +const MACOS_DEFAULT_BYPASS: &str = + "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,"; +const MAX_SERVICE_BYPASS_LEN: usize = 8192; + +static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new(); + +struct ServiceProxyOperations { + guard_generation: AtomicU64, + operation_lock: Mutex<()>, +} + +impl ServiceProxyOperations { + const fn new() -> Self { + Self { + guard_generation: AtomicU64::new(0), + operation_lock: Mutex::const_new(()), + } + } + + fn invalidate_guard(&self) -> u64 { + self.guard_generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1) + } + + async fn run_service_operation(&self, operation: F) -> T + where + F: FnOnce() -> Fut, + Fut: Future, + { + let _operation = self.operation_lock.lock().await; + operation().await + } + + async fn run_final_service_operation(&self, operation: F) -> T + where + F: FnOnce() -> Fut, + Fut: Future, + { + self.invalidate_guard(); + self.run_service_operation(operation).await + } + + async fn cancel_and_drain(&self, cancel: F) -> u64 + where + F: FnOnce() -> Fut, + Fut: Future, + { + let generation = self.invalidate_guard(); + self.run_service_operation(cancel).await; + generation + } + + async fn run_guard_request( + &self, + captured_generation: u64, + captured_proof: &OwnerSessionProof, + is_service_mode: Mode, + active_proof: ActiveProof, + request: Request, + ) -> Result + where + Mode: FnOnce() -> bool, + ActiveProof: FnOnce() -> Result, + Request: FnOnce() -> RequestFuture, + RequestFuture: Future>, + { + self.run_service_operation(|| async move { + if !guard_generation_is_current(&self.guard_generation, captured_generation) + || !is_service_mode() + || !active_proof().is_ok_and(|active| active == *captured_proof) + { + return Ok(false); + } + service_apply_result(request().await?)?; + Ok(true) + }) + .await + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProxyBackendRoute { + Local, + Service, +} + +const fn proxy_backend_route(is_macos: bool, running_mode: &RunningMode) -> ProxyBackendRoute { + if is_macos && matches!(running_mode, RunningMode::Service) { + ProxyBackendRoute::Service + } else { + ProxyBackendRoute::Local + } +} + +fn guard_generation_is_current(generation: &AtomicU64, captured_generation: u64) -> bool { + generation.load(Ordering::Acquire) == captured_generation +} + +fn truncate_utf8(value: &mut String, max_bytes: usize) { + if value.len() <= max_bytes { + return; + } + let mut boundary = max_bytes; + while !value.is_char_boundary(boundary) { + boundary -= 1; + } + value.truncate(boundary); +} + +fn service_bypass(verge: &IVerge) -> Result { + let custom = verge.system_proxy_bypass.as_deref().unwrap_or(""); + ensure!(!custom.contains('\0'), "system proxy bypass contains NUL"); + + let mut bypass = if custom.is_empty() { + MACOS_DEFAULT_BYPASS.to_owned() + } else if verge.use_default_bypass.unwrap_or(true) { + format!("{MACOS_DEFAULT_BYPASS},{custom}") + } else { + custom.to_owned() + }; + truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN); + Ok(bypass) +} + +fn service_proxy_config(verge: &IVerge, mixed_port: u16, pac_port: u16) -> Result { + if !verge.enable_system_proxy.unwrap_or_default() { + return Ok(MacosProxyConfig::Disabled); + } + + if verge.proxy_auto_config.unwrap_or_default() { + ensure!(pac_port != 0, "embedded PAC server port must not be zero"); + Ok(MacosProxyConfig::Pac { + url: format!("http://{LOOPBACK_HOST}:{pac_port}/commands/pac"), + }) + } else { + ensure!(mixed_port != 0, "system proxy port must not be zero"); + Ok(MacosProxyConfig::Global { + host: LOOPBACK_HOST.to_owned(), + port: mixed_port, + bypass: service_bypass(verge)?, + }) + } +} + +fn service_apply_result(outcome: ProxyApplyOutcome) -> Result<()> { + match outcome { + ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()), + ProxyApplyOutcome::DirectFallback { message } => bail!(message), + } +} + +async fn current_service_proxy_config(verge: &IVerge) -> Result { + if !verge.enable_system_proxy.unwrap_or_default() { + return Ok(MacosProxyConfig::Disabled); + } + if verge.proxy_auto_config.unwrap_or_default() { + return service_proxy_config(verge, 0, server::embedded_server_port()?); + } + let mixed_port = match verge.verge_mixed_port { + Some(port) => port, + None => Config::clash().await.latest_arc().get_mixed_port(), + }; + service_proxy_config(verge, mixed_port, 0) +} + +pub async fn apply() -> Result<()> { + let running_mode = CoreManager::global().get_running_mode(); + match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) { + ProxyBackendRoute::Local => Sysopt::global().update_sysproxy().await, + ProxyBackendRoute::Service => { + let verge = Config::verge().await.latest_arc(); + let proxy = current_service_proxy_config(&verge).await?; + SERVICE_PROXY_OPERATIONS + .run_final_service_operation(|| async { + service_apply_result(service::set_system_proxy_by_service(&proxy).await?) + }) + .await + } + } +} + +pub async fn clear() -> Result<()> { + let running_mode = CoreManager::global().get_running_mode(); + match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) { + ProxyBackendRoute::Local => Sysopt::global().reset_sysproxy().await, + ProxyBackendRoute::Service => { + SERVICE_PROXY_OPERATIONS + .run_final_service_operation(|| async { + service_apply_result(service::set_system_proxy_by_service(&MacosProxyConfig::Disabled).await?) + }) + .await + } + } +} + +pub async fn refresh_guard() -> Result<()> { + let generation = SERVICE_PROXY_OPERATIONS + .cancel_and_drain(|| Sysopt::global().stop_proxy_guard()) + .await; + let running_mode = CoreManager::global().get_running_mode(); + if matches!( + proxy_backend_route(cfg!(target_os = "macos"), &running_mode), + ProxyBackendRoute::Local + ) { + Sysopt::global().refresh_guard().await; + return Ok(()); + } + + let verge = Config::verge().await.latest_arc(); + if !verge.enable_system_proxy.unwrap_or_default() || !verge.enable_proxy_guard.unwrap_or_default() { + return Ok(()); + } + + let proxy = current_service_proxy_config(&verge).await?; + let proof = service::active_service_session()?; + let interval = Duration::from_secs(verge.proxy_guard_duration.unwrap_or(30).max(1)); + AsyncHandler::spawn(move || async move { + loop { + tokio::time::sleep(interval).await; + match SERVICE_PROXY_OPERATIONS + .run_guard_request( + generation, + &proof, + || matches!(*CoreManager::global().get_running_mode(), RunningMode::Service), + service::active_service_session, + || service::set_system_proxy_by_service_with_session(&proxy, &proof), + ) + .await + { + Ok(true) => {} + Ok(false) => break, + Err(_) => logging!(warn, Type::Core, "failed to refresh system proxy through Service"), + } + } + }); + Ok(()) +} + +pub async fn stop_guard() { + SERVICE_PROXY_OPERATIONS + .cancel_and_drain(|| Sysopt::global().stop_proxy_guard()) + .await; +} + +#[cfg(test)] +mod tests { + use super::{ + ProxyBackendRoute, ServiceProxyOperations, guard_generation_is_current, proxy_backend_route, + service_proxy_config, + }; + use crate::{config::IVerge, core::manager::RunningMode}; + use clash_verge_service_ipc::{MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome}; + use parking_lot::Mutex; + use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }; + use std::task::Poll; + use tokio::sync::Barrier; + + fn proof(generation: u64, token: &str) -> OwnerSessionProof { + OwnerSessionProof { + generation, + token: token.to_owned(), + } + } + + #[test] + fn only_macos_service_routes_proxy_to_helper() { + assert_eq!( + proxy_backend_route(true, &RunningMode::Service), + ProxyBackendRoute::Service + ); + assert_eq!( + proxy_backend_route(true, &RunningMode::Sidecar), + ProxyBackendRoute::Local + ); + assert_eq!( + proxy_backend_route(false, &RunningMode::Service), + ProxyBackendRoute::Local + ); + } + + #[test] + fn stale_periodic_refresh_stops_after_owner_loss() { + let generation = AtomicU64::new(7); + let captured_generation = generation.load(Ordering::Acquire); + + assert!(guard_generation_is_current(&generation, captured_generation)); + generation.fetch_add(1, Ordering::AcqRel); + assert!(!guard_generation_is_current(&generation, captured_generation)); + } + + #[tokio::test] + async fn final_service_mutation_finishes_after_in_flight_guard_request() { + let operations = Arc::new(ServiceProxyOperations::new()); + let generation = operations.invalidate_guard(); + let captured_proof = proof(1, "captured"); + let active_proof = Arc::new(Mutex::new(captured_proof.clone())); + let guard_started = Arc::new(Barrier::new(2)); + let release_guard = Arc::new(Barrier::new(2)); + let events = Arc::new(Mutex::new(Vec::new())); + + let guard_task = { + let operations = Arc::clone(&operations); + let captured_proof = captured_proof.clone(); + let active_proof = Arc::clone(&active_proof); + let guard_started = Arc::clone(&guard_started); + let release_guard = Arc::clone(&release_guard); + let events = Arc::clone(&events); + tokio::spawn(async move { + operations + .run_guard_request( + generation, + &captured_proof, + || true, + || Ok(active_proof.lock().clone()), + || async { + events.lock().push("guard-started"); + guard_started.wait().await; + release_guard.wait().await; + events.lock().push("guard-finished"); + Ok(ProxyApplyOutcome::Applied) + }, + ) + .await + }) + }; + guard_started.wait().await; + + let mut final_operation = Box::pin(operations.run_final_service_operation(|| async { + events.lock().push("final"); + Ok::<_, anyhow::Error>(()) + })); + assert!(matches!(futures::poll!(final_operation.as_mut()), Poll::Pending)); + assert!(!guard_generation_is_current(&operations.guard_generation, generation)); + release_guard.wait().await; + + assert!(matches!(guard_task.await, Ok(Ok(true)))); + assert!(matches!(final_operation.await, Ok(()))); + assert_eq!(&*events.lock(), &["guard-started", "guard-finished", "final"]); + } + + #[tokio::test] + async fn generation_invalidation_while_guard_waits_prevents_rpc() { + let operations = Arc::new(ServiceProxyOperations::new()); + let generation = operations.invalidate_guard(); + let captured_proof = proof(1, "captured"); + let blocker_started = Arc::new(Barrier::new(2)); + let release_blocker = Arc::new(Barrier::new(2)); + + let blocker = { + let operations = Arc::clone(&operations); + let blocker_started = Arc::clone(&blocker_started); + let release_blocker = Arc::clone(&release_blocker); + tokio::spawn(async move { + operations + .run_service_operation(|| async { + blocker_started.wait().await; + release_blocker.wait().await; + }) + .await + }) + }; + blocker_started.wait().await; + + let rpc_called = AtomicBool::new(false); + let mut guard_request = Box::pin(operations.run_guard_request( + generation, + &captured_proof, + || true, + || Ok(captured_proof.clone()), + || async { + rpc_called.store(true, Ordering::Release); + Ok(ProxyApplyOutcome::Applied) + }, + )); + assert!(matches!(futures::poll!(guard_request.as_mut()), Poll::Pending)); + operations.invalidate_guard(); + release_blocker.wait().await; + + assert!(blocker.await.is_ok()); + assert!(matches!(guard_request.await, Ok(false))); + assert!(!rpc_called.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn old_guard_loop_does_not_adopt_replacement_proof() { + let operations = Arc::new(ServiceProxyOperations::new()); + let generation = operations.invalidate_guard(); + let captured_proof = proof(1, "captured"); + let replacement_proof = proof(2, "replacement"); + let active_proof = Mutex::new(captured_proof.clone()); + let rpc_called = AtomicBool::new(false); + let blocker_started = Arc::new(Barrier::new(2)); + let release_blocker = Arc::new(Barrier::new(2)); + + let blocker = { + let operations = Arc::clone(&operations); + let blocker_started = Arc::clone(&blocker_started); + let release_blocker = Arc::clone(&release_blocker); + tokio::spawn(async move { + operations + .run_service_operation(|| async { + blocker_started.wait().await; + release_blocker.wait().await; + }) + .await + }) + }; + blocker_started.wait().await; + + let mut guard_request = Box::pin(operations.run_guard_request( + generation, + &captured_proof, + || true, + || Ok(active_proof.lock().clone()), + || async { + rpc_called.store(true, Ordering::Release); + Ok(ProxyApplyOutcome::Applied) + }, + )); + assert!(matches!(futures::poll!(guard_request.as_mut()), Poll::Pending)); + *active_proof.lock() = replacement_proof; + release_blocker.wait().await; + + assert!(blocker.await.is_ok()); + assert!(matches!(guard_request.await, Ok(false))); + assert!(!rpc_called.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn guard_refresh_rejects_direct_fallback() { + let operations = ServiceProxyOperations::new(); + let generation = operations.invalidate_guard(); + let captured_proof = proof(1, "captured"); + + let refreshed = operations + .run_guard_request( + generation, + &captured_proof, + || true, + || Ok(captured_proof.clone()), + || async { + Ok(ProxyApplyOutcome::DirectFallback { + message: "fallback detail".to_owned(), + }) + }, + ) + .await; + + assert!(refreshed.is_err()); + } + + #[test] + fn service_proxy_config_forces_loopback_targets_and_bounds_bypass() { + let verge = IVerge { + enable_system_proxy: Some(true), + proxy_auto_config: Some(false), + proxy_host: Some("192.0.2.1".into()), + system_proxy_bypass: Some(format!("{}界", "x".repeat(8191)).into()), + use_default_bypass: Some(false), + ..IVerge::default() + }; + + let proxy = service_proxy_config(&verge, 7897, 3333).unwrap_or_else(|_| unreachable!()); + let MacosProxyConfig::Global { host, port, bypass } = proxy else { + unreachable!(); + }; + + assert_eq!(host, "127.0.0.1"); + assert_eq!(port, 7897); + assert!(bypass.len() <= 8192); + assert!(bypass.is_char_boundary(bypass.len())); + + let pac_verge = IVerge { + proxy_auto_config: Some(true), + ..verge + }; + assert_eq!( + service_proxy_config(&pac_verge, 7897, 3333).unwrap_or_else(|_| unreachable!()), + MacosProxyConfig::Pac { + url: "http://127.0.0.1:3333/commands/pac".to_owned() + } + ); + } +} diff --git a/src-tauri/src/core/runtime_bundle.rs b/src-tauri/src/core/runtime_bundle.rs new file mode 100644 index 000000000..df55ef3df --- /dev/null +++ b/src-tauri/src/core/runtime_bundle.rs @@ -0,0 +1,250 @@ +use anyhow::{Context as _, Result, bail}; +use clash_verge_service_ipc::{RuntimeAsset, RuntimeBundle}; +use serde_yaml_ng::Value; +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; + +const GEO_ASSETS: &[&str] = &[ + "Country.mmdb", + "geoip.dat", + "geosite.dat", + "geoip.metadb", + "GeoSite.dat", +]; + +pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path) -> Result { + let yaml = tokio::fs::read_to_string(config_file) + .await + .with_context(|| format!("failed to read runtime config {config_file:?}"))?; + let mut config: Value = + serde_yaml_ng::from_str(&yaml).with_context(|| format!("failed to parse runtime config {config_file:?}"))?; + let config_root = config_file + .parent() + .ok_or_else(|| anyhow::anyhow!("runtime config has no parent directory"))?; + let config_root = std::fs::canonicalize(config_root)?; + let mut assets = Vec::new(); + let mut destinations = HashSet::new(); + + collect_provider_assets( + &mut config, + "proxy-providers", + &config_root, + &mut destinations, + &mut assets, + )?; + collect_provider_assets( + &mut config, + "rule-providers", + &config_root, + &mut destinations, + &mut assets, + )?; + for filename in GEO_ASSETS { + let source = config_root.join(filename); + if source.is_file() && destinations.insert((*filename).to_string()) { + assets.push(RuntimeAsset { + source: std::fs::canonicalize(&source)?.to_string_lossy().into_owned(), + destination: (*filename).to_string(), + }); + } + } + + Ok(RuntimeBundle { + yaml: serde_yaml_ng::to_string(&config).context("failed to serialize service runtime config")?, + assets, + core_path: core_path.to_string_lossy().into_owned(), + }) +} + +fn collect_provider_assets( + config: &mut Value, + section: &str, + config_root: &Path, + destinations: &mut HashSet, + assets: &mut Vec, +) -> Result<()> { + let Some(providers) = config + .as_mapping_mut() + .and_then(|mapping| mapping.get_mut(section)) + .and_then(Value::as_mapping_mut) + else { + return Ok(()); + }; + + for provider in providers.values_mut().filter_map(Value::as_mapping_mut) { + let Some(raw_path) = provider.get("path").and_then(Value::as_str) else { + continue; + }; + let is_remote = provider.get("url").and_then(Value::as_str).is_some(); + let destination = if is_remote { + provider_destination(config_root, raw_path)? + } else { + let source = local_provider_source(config_root, raw_path)?; + let destination = destination_below_root(config_root, &source)?; + if destinations.insert(destination.clone()) { + assets.push(RuntimeAsset { + source: source.to_string_lossy().into_owned(), + destination: destination.clone(), + }); + } + destination + }; + provider.insert(Value::String("path".to_owned()), Value::String(destination)); + } + Ok(()) +} + +fn local_provider_source(config_root: &Path, raw_path: &str) -> Result { + let path = Path::new(raw_path); + let source = if path.is_absolute() { + path.to_path_buf() + } else { + config_root.join(path) + }; + let canonical_source = + std::fs::canonicalize(&source).with_context(|| format!("local runtime provider is unavailable: {source:?}"))?; + canonical_source + .strip_prefix(config_root) + .map_err(|_| anyhow::anyhow!("local runtime provider is outside the config root: {canonical_source:?}"))?; + Ok(canonical_source) +} + +fn provider_destination(config_root: &Path, raw_path: &str) -> Result { + let path = Path::new(raw_path); + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + bail!("runtime provider destination traverses outside the runtime"); + } + if path.is_absolute() { + let normalized = canonicalize_with_missing_tail(path)?; + destination_below_root(config_root, &normalized) + } else { + normalized_destination(path) + } +} + +fn canonicalize_with_missing_tail(path: &Path) -> Result { + let mut ancestor = path; + let mut tail = Vec::new(); + while !ancestor.exists() { + let name = ancestor + .file_name() + .ok_or_else(|| anyhow::anyhow!("runtime provider path has no existing ancestor: {path:?}"))?; + tail.push(name.to_owned()); + ancestor = ancestor + .parent() + .ok_or_else(|| anyhow::anyhow!("runtime provider path has no existing ancestor: {path:?}"))?; + } + let mut normalized = std::fs::canonicalize(ancestor)?; + for component in tail.iter().rev() { + normalized.push(component); + } + Ok(normalized) +} + +fn destination_below_root(config_root: &Path, path: &Path) -> Result { + let relative = path + .strip_prefix(config_root) + .map_err(|_| anyhow::anyhow!("runtime provider path is outside the config root: {path:?}"))?; + normalized_destination(relative) +} + +fn normalized_destination(relative: &Path) -> Result { + let mut destination = PathBuf::new(); + for component in relative.components() { + match component { + Component::Normal(component) => destination.push(component), + Component::CurDir => {} + _ => bail!("local runtime provider destination traverses outside the runtime"), + } + } + if destination.as_os_str().is_empty() { + bail!("local runtime provider destination is empty"); + } + Ok(destination.to_string_lossy().replace('\\', "/")) +} + +#[cfg(test)] +mod tests { + use super::collect_runtime_bundle; + + #[tokio::test] + async fn collects_only_local_providers_and_existing_geo_assets() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("clash-verge-runtime-bundle-{}", std::process::id())); + std::fs::create_dir_all(root.join("providers"))?; + std::fs::write(root.join("providers/local.yaml"), b"proxies: []\n")?; + std::fs::write(root.join("Country.mmdb"), b"geo")?; + let config = root.join("config.yaml"); + let absolute_local = root.join("providers/local.yaml"); + let absolute_remote = root.join("providers/remote.yaml"); + std::fs::write( + &config, + format!( + "proxy-providers:\n local:\n type: file\n path: {}\n remote:\n type: http\n url: https://example.com/p.yaml\n path: {}\nrule-providers: {{}}\n", + absolute_local.display(), + absolute_remote.display() + ), + )?; + let core = root.join("mihomo"); + std::fs::write(&core, b"core")?; + + let bundle = collect_runtime_bundle(&config, &core).await?; + + let bundled_config: serde_yaml_ng::Value = serde_yaml_ng::from_str(&bundle.yaml)?; + assert_eq!( + bundled_config["proxy-providers"]["local"]["path"].as_str(), + Some("providers/local.yaml") + ); + assert_eq!( + bundled_config["proxy-providers"]["remote"]["path"].as_str(), + Some("providers/remote.yaml") + ); + assert!(!bundle.yaml.contains(&root.to_string_lossy().into_owned())); + assert!( + bundle + .assets + .iter() + .any(|asset| asset.destination == "providers/local.yaml") + ); + assert!(bundle.assets.iter().any(|asset| asset.destination == "Country.mmdb")); + assert!(!bundle.assets.iter().any(|asset| asset.destination.contains("remote"))); + assert!( + !bundle + .assets + .iter() + .any(|asset| asset.destination.contains("geoip.dat")) + ); + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[tokio::test] + async fn rejects_provider_paths_outside_the_config_root() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("clash-verge-runtime-bundle-outside-{}", std::process::id())); + std::fs::create_dir_all(&root)?; + let config = root.join("config.yaml"); + let outside = root + .parent() + .ok_or_else(|| anyhow::anyhow!("test root has no parent"))? + .join(format!("outside-{}.yaml", std::process::id())); + std::fs::write( + &config, + format!( + "proxy-providers:\n remote:\n type: http\n url: https://example.com/p.yaml\n path: {}\n", + outside.display() + ), + )?; + let core = root.join("mihomo"); + std::fs::write(&core, b"core")?; + + let Err(error) = collect_runtime_bundle(&config, &core).await else { + anyhow::bail!("outside provider path must be rejected"); + }; + + assert!(error.to_string().contains("outside the config root")); + std::fs::remove_dir_all(root)?; + Ok(()) + } +} diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 50c98e5ac..e38b230e1 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -1,145 +1,555 @@ +#[cfg(any(target_os = "macos", target_os = "windows"))] +use crate::utils::dirs; use crate::{ - config::{Config, IClashTemp}, - core::{logger::Logger, tray::Tray}, - utils::dirs, + config::Config, + core::{ + CoreManager, handle::Handle, manager::RunningMode, owner_identity::current_owner_credentials, proxy_control, + runtime_bundle::collect_runtime_bundle, tray::Tray, + }, + process::AsyncHandler, + utils::server, }; use anyhow::{Context as _, Result, bail}; -use backon::{ConstantBuilder, Retryable as _}; use clash_verge_logging::{Type, logging}; -use clash_verge_service_ipc::CoreConfig; +use clash_verge_service_ipc::{ + MIN_REQUIRED_SERVICE_REVISION, MacosProxyConfig, OwnerSessionProof, ProtocolInfo, ProtocolVersion, + ProxyApplyOutcome, StartClashRequest, WriterConfig, +}; use compact_str::CompactString; use once_cell::sync::Lazy; use parking_lot::Mutex; -use scopeguard::defer; use std::{ borrow::Cow, env::current_exe, future::Future, path::{Path, PathBuf}, process::Command as StdCommand, - sync::atomic::{AtomicBool, Ordering}, + sync::atomic::{AtomicBool, AtomicU64, Ordering}, time::Duration, }; use tokio::sync::Notify; +static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0); +static ACTIVE_SERVICE_SESSION: Lazy>> = Lazy::new(|| Mutex::new(None)); + +fn generate_service_session_token() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).context("failed to generate service owner session")?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +pub(crate) fn active_service_session() -> Result { + ACTIVE_SERVICE_SESSION + .lock() + .clone() + .context("service owner session is not active") +} + +pub(crate) fn clear_active_service_session() { + ACTIVE_SERVICE_SESSION.lock().take(); +} + +fn session_matches_status(proof: &OwnerSessionProof, is_active: bool, active_generation: Option) -> bool { + is_active && active_generation == Some(proof.generation) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ServiceStatus { + Checking, Ready, + NotInstalled, NeedsReinstall, InstallRequired, UninstallRequired, ReinstallRequired, ForceReinstallRequired, + SidecarAllowed, Unavailable(String), } +impl ServiceStatus { + const fn install_state(&self) -> ServiceInstallState { + match self { + Self::Checking => ServiceInstallState::Checking, + Self::Ready => ServiceInstallState::Ready, + Self::NotInstalled => ServiceInstallState::NotInstalled, + Self::InstallRequired => ServiceInstallState::InstallRequired, + Self::SidecarAllowed => ServiceInstallState::SidecarAllowed, + Self::NeedsReinstall | Self::ReinstallRequired | Self::ForceReinstallRequired => { + ServiceInstallState::NeedsReinstall + } + Self::UninstallRequired | Self::Unavailable(_) => ServiceInstallState::Unavailable, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ServiceInstallState { + Checking, + NotInstalled, + InstallRequired, + Ready, + NeedsReinstall, + SidecarAllowed, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CurrentServiceProbe { + Missing, + Ready, + VersionMismatch, + Unavailable, +} + +const fn classify_service_install_state(current: CurrentServiceProbe, has_install_marker: bool) -> ServiceInstallState { + match current { + CurrentServiceProbe::Ready => ServiceInstallState::Ready, + CurrentServiceProbe::VersionMismatch => ServiceInstallState::NeedsReinstall, + CurrentServiceProbe::Unavailable if has_install_marker => ServiceInstallState::NeedsReinstall, + CurrentServiceProbe::Unavailable => ServiceInstallState::Unavailable, + CurrentServiceProbe::Missing if has_install_marker => ServiceInstallState::NeedsReinstall, + CurrentServiceProbe::Missing => ServiceInstallState::NotInstalled, + } +} + +fn confirmed_service_available(result: std::result::Result) -> bool { + matches!(result, Ok(true)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ServiceVersionReply { + code: u16, + message: String, + protocol: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ServiceVersionCheck { + Ready, + NeedsReinstall(String), +} + +fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVersionCheck { + let client = ProtocolVersion::current(); + if reply.code == 0 + && reply + .protocol + .as_ref() + .is_some_and(|info| info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION)) + { + return ServiceVersionCheck::Ready; + } + + let detail = if reply.code == 0 { + match reply.protocol.as_ref() { + Some(info) => format!( + "client requires epoch {} revision >= {}, service reports epoch {} revision {} (build {})", + client.epoch, + MIN_REQUIRED_SERVICE_REVISION, + info.protocol.epoch, + info.protocol.revision, + info.build_version + ), + None => "service did not report protocol information".to_owned(), + } + } else { + format!( + "protocol query returned code {} ({}) while expecting epoch {}", + reply.code, reply.message, client.epoch + ) + }; + ServiceVersionCheck::NeedsReinstall(format!( + "Service helper protocol mismatch: {detail}. Choose Reinstall or Repair to continue" + )) +} + +async fn probe_service_version_once() -> Result { + if !is_service_ipc_path_exists() { + bail!("service IPC path is not available"); + } + + let response = clash_verge_service_ipc::get_version().await?; + Ok(ServiceVersionReply { + code: response.code, + message: response.message, + protocol: response.data, + }) +} + +async fn poll_service_version( + max_attempts: usize, + retry_delay: Duration, + mut probe: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut last_error = None; + for attempt in 0..max_attempts { + match probe().await { + Ok(reply) => return Ok(reply), + Err(error) => last_error = Some(error), + } + if attempt + 1 < max_attempts { + tokio::time::sleep(retry_delay).await; + } + } + + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("service version probe was configured with no attempts"))) +} + +fn apply_service_version_reply(manager: &ServiceManager, reply: &ServiceVersionReply) -> Result<()> { + match classify_service_version_reply(reply) { + ServiceVersionCheck::Ready => { + manager.set_status(ServiceStatus::Ready); + Ok(()) + } + ServiceVersionCheck::NeedsReinstall(error) => { + manager.set_status(ServiceStatus::NeedsReinstall); + bail!(error) + } + } +} + +fn apply_service_version_result( + manager: &ServiceManager, + result: Result, + unavailable_context: &'static str, +) -> Result<()> { + match result { + Ok(reply) => apply_service_version_reply(manager, &reply), + Err(error) => { + manager.set_status(ServiceStatus::Unavailable(format!("{unavailable_context}: {error:#}"))); + Err(error).context(unavailable_context) + } + } +} + +#[cfg(target_os = "macos")] +fn path_entry_exists_without_follow(path: &Path) -> std::io::Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +#[cfg(target_os = "macos")] +fn macos_service_install_markers() -> Vec { + vec![ + format!( + "/Library/LaunchDaemons/{}.plist", + clash_verge_service_ipc::MACOS_SERVICE_ID + ), + format!( + "/Library/PrivilegedHelperTools/{}.bundle", + clash_verge_service_ipc::MACOS_SERVICE_ID + ), + #[cfg(not(feature = "verge-dev"))] + "/Library/LaunchDaemons/io.github.clashverge.helper.plist".to_owned(), + #[cfg(not(feature = "verge-dev"))] + "/Library/PrivilegedHelperTools/io.github.clashverge.helper".to_owned(), + ] +} + +#[cfg(target_os = "macos")] +fn macos_service_install_marker_exists() -> std::io::Result { + for marker in macos_service_install_markers() { + if path_entry_exists_without_follow(Path::new(&marker))? { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(windows)] +fn trusted_service_evidence() -> Result { + use windows_service::{ + Error as WindowsServiceError, + service::ServiceAccess, + service_manager::{ServiceManager as WindowsServiceManager, ServiceManagerAccess}, + }; + + const ERROR_SERVICE_DOES_NOT_EXIST: i32 = 1060; + let manager = WindowsServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?; + match manager.open_service( + clash_verge_service_ipc::WINDOWS_SERVICE_NAME, + ServiceAccess::QUERY_STATUS, + ) { + Ok(service) => { + drop(service); + Ok(true) + } + Err(WindowsServiceError::Winapi(error)) if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST) => { + Ok(false) + } + Err(error) => Err(error).context("failed to inspect Windows service registration"), + } +} + +#[cfg(target_os = "linux")] +fn trusted_service_evidence() -> Result { + let unit = format!("{}.service", clash_verge_service_ipc::SERVICE_SLUG); + let output = StdCommand::new("systemctl") + .args(["show", "--property=LoadState", "--value", &unit]) + .output() + .context("failed to inspect systemd service registration")?; + if !output.status.success() { + bail!( + "systemd service registration probe failed with status {}", + output.status + ); + } + Ok(String::from_utf8_lossy(&output.stdout).trim() != "not-found") +} + +#[cfg(target_os = "macos")] +fn trusted_service_evidence() -> Result { + macos_service_install_marker_exists().context("failed to inspect launchd service registration") +} + +async fn detect_service_install_state() -> ServiceInstallState { + let current = if is_service_ipc_path_exists() { + match probe_service_version_once().await { + Ok(reply) if classify_service_version_reply(&reply) == ServiceVersionCheck::Ready => { + CurrentServiceProbe::Ready + } + Ok(_) => CurrentServiceProbe::VersionMismatch, + Err(error) => { + logging!(warn, Type::Service, "current service IPC is unavailable: {error:#}"); + CurrentServiceProbe::Unavailable + } + } + } else { + CurrentServiceProbe::Missing + }; + + let markers = match trusted_service_evidence() { + Ok(exists) => exists, + Err(error) => { + logging!( + warn, + Type::Service, + "failed to inspect trusted service evidence: {error:#}" + ); + return ServiceInstallState::Unavailable; + } + }; + classify_service_install_state(current, markers) +} + pub struct ServiceManager { status: Mutex, + status_generation: AtomicU64, operation_running: AtomicBool, operation_done: Notify, } -#[cfg(not(target_os = "macos"))] -fn service_core_path(clash_core: &str, bin_ext: &str) -> Result { - Ok(current_exe()?.with_file_name(format!("{clash_core}{bin_ext}"))) -} +#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))] +static SERVICE_CORE_STAGING_GENERATION: AtomicU64 = AtomicU64::new(0); -#[cfg(target_os = "macos")] -fn service_core_path(clash_core: &str, bin_ext: &str) -> Result { - let binary_name = format!("{clash_core}{bin_ext}"); - let exe_path = current_exe()?; - let candidate = exe_path.with_file_name(&binary_name); - - if !is_macos_app_translocated(&exe_path) { - return Ok(candidate); - } - - if let Some(stable_path) = stable_macos_core_path_for_translocated_app(&exe_path, &binary_name) { - logging!( - warn, - Type::Service, - "macOS App Translocation detected for core path {:?}; using stable installed path {:?}", - candidate, - stable_path +#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))] +fn create_service_core_staging_file(directory: &Path, core_name: &std::ffi::OsStr) -> Result<(PathBuf, std::fs::File)> { + for _ in 0..32 { + let generation = SERVICE_CORE_STAGING_GENERATION.fetch_add(1, Ordering::Relaxed); + let temporary_name = format!( + ".{}.{}.{generation}.tmp", + core_name.to_string_lossy(), + std::process::id() ); - return Ok(stable_path); - } - - // 给用户一个可操作的提示,再 bail 让服务启动失败 —— 避免用临时路径起内核。 - notify_translocated_core_path(); - bail!( - "macOS App Translocation detected; refusing to start service with temporary core path {:?}", - candidate - ) -} - -/// 发送 translocation 用户提示。**延迟**发送:app 启动期会自动尝试起 core,此时前端的 -/// `verge://notice-message` 监听器(随 React 布局挂载后才注册)可能尚未就绪,而后端 emit -/// 没有重放队列 —— 立即发会丢失。延迟到前端挂载后再发,既覆盖"启动自动起 core 失败"、 -/// 也兼顾手动启动(错误提示略迟可接受)。复用前端 `set_config::error` 处理器直接展示该消息。 -#[cfg(target_os = "macos")] -fn notify_translocated_core_path() { - crate::process::AsyncHandler::spawn(|| async { - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - crate::core::handle::Handle::notice_message( - "set_config::error", - clash_verge_i18n::t!("service.translocatedCorePath").to_string(), - ); - }); -} - -#[cfg(target_os = "macos")] -fn is_macos_app_translocated(path: &Path) -> bool { - path.components() - .any(|component| component.as_os_str() == "AppTranslocation") -} - -#[cfg(target_os = "macos")] -fn stable_macos_core_path_for_translocated_app(exe_path: &Path, binary_name: &str) -> Option { - let bundle_name = macos_app_bundle_name(exe_path)?; - macos_core_path_in_install_roots( - &bundle_name, - binary_name, - [Path::new("/Applications"), Path::new("/Applications/Utilities")], - ) -} - -#[cfg(target_os = "macos")] -fn macos_app_bundle_name(path: &Path) -> Option { - path.ancestors().find_map(|ancestor| { - let is_app_bundle = ancestor - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("app")); - - if is_app_bundle { - ancestor.file_name().map(std::ffi::OsString::from) - } else { - None + let temporary_path = directory.join(temporary_name); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary_path) + { + Ok(file) => return Ok((temporary_path, file)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to create temporary development Service core {}", + temporary_path.display() + ) + }); + } } - }) + } + + bail!( + "failed to create a unique temporary development Service core in {}", + directory.display() + ) +} + +#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))] +fn service_core_path_for(source: &Path, home: Option<&Path>, stage_for_macos_dev: bool) -> Result { + service_core_path_for_with_publisher( + source, + home, + stage_for_macos_dev, + "service-core", + |temporary_path, final_path| { + std::fs::rename(temporary_path, final_path).with_context(|| { + format!( + "failed to publish development Service core {} over {}", + temporary_path.display(), + final_path.display() + ) + }) + }, + ) +} + +#[cfg(any(all(target_os = "macos", feature = "verge-dev"), all(test, unix)))] +fn service_tool_path_for(source: &Path, home: Option<&Path>, stage_for_macos_dev: bool) -> Result { + service_core_path_for_with_publisher( + source, + home, + stage_for_macos_dev, + "service-tools", + |temporary_path, final_path| { + std::fs::rename(temporary_path, final_path).with_context(|| { + format!( + "failed to publish development Service tool {} over {}", + temporary_path.display(), + final_path.display() + ) + }) + }, + ) +} + +#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))] +#[cfg_attr(not(unix), allow(unreachable_code, unused_assignments, unused_variables))] +fn service_core_path_for_with_publisher( + source: &Path, + home: Option<&Path>, + stage_for_macos_dev: bool, + staging_directory_name: &str, + publisher: F, +) -> Result +where + F: FnOnce(&Path, &Path) -> Result<()>, +{ + if !stage_for_macos_dev { + return Ok(source.to_path_buf()); + } + + let home = home + .filter(|path| !path.as_os_str().is_empty()) + .context("HOME is unavailable for development Service core staging")?; + let core_name = source + .file_name() + .filter(|name| !name.is_empty()) + .with_context(|| format!("development Service core source has no file name: {}", source.display()))?; + let source_metadata = std::fs::symlink_metadata(source) + .with_context(|| format!("failed to inspect development Service core source {}", source.display()))?; + if !source_metadata.file_type().is_file() { + bail!( + "development Service core source is not an ordinary file: {}", + source.display() + ); + } + let mut source_file = std::fs::File::open(source) + .with_context(|| format!("failed to open development Service core source {}", source.display()))?; + + let staging_directory = home + .join("Applications/.clash-verge-rev-dev") + .join(staging_directory_name); + std::fs::create_dir_all(&staging_directory).with_context(|| { + format!( + "failed to create development Service core staging directory {}", + staging_directory.display() + ) + })?; + let final_path = staging_directory.join(core_name); + let (temporary_path, mut temporary_file) = create_service_core_staging_file(&staging_directory, core_name)?; + + let publish_result = (|| -> Result<()> { + std::io::copy(&mut source_file, &mut temporary_file).with_context(|| { + format!( + "failed to copy development Service core from {} to {}", + source.display(), + temporary_path.display() + ) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + let mut permissions = temporary_file + .metadata() + .with_context(|| format!("failed to inspect temporary Service core {}", temporary_path.display()))? + .permissions(); + permissions.set_mode(0o755); + temporary_file.set_permissions(permissions).with_context(|| { + format!( + "failed to set executable permissions on temporary Service core {}", + temporary_path.display() + ) + })?; + } + #[cfg(not(unix))] + bail!("development Service core staging requires Unix executable permissions"); + + temporary_file + .sync_all() + .with_context(|| format!("failed to sync temporary Service core {}", temporary_path.display()))?; + drop(temporary_file); + publisher(&temporary_path, &final_path)?; + Ok(()) + })(); + + if let Err(error) = publish_result { + match std::fs::remove_file(&temporary_path) { + Ok(()) => return Err(error), + Err(cleanup_error) if cleanup_error.kind() == std::io::ErrorKind::NotFound => return Err(error), + Err(cleanup_error) => { + return Err(error).with_context(|| { + format!( + "failed to clean temporary development Service core {}: {cleanup_error}", + temporary_path.display() + ) + }); + } + } + } + + Ok(final_path) } #[cfg(target_os = "macos")] -fn macos_core_path_in_install_roots<'a>( - bundle_name: &std::ffi::OsStr, - binary_name: &str, - install_roots: impl IntoIterator, -) -> Option { - install_roots.into_iter().find_map(|root| { - let core_path = root - .join(Path::new(bundle_name)) - .join("Contents") - .join("MacOS") - .join(binary_name); +fn macos_service_tool_path(source: &Path) -> Result { + #[cfg(feature = "verge-dev")] + { + let home = std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + service_tool_path_for(source, home.as_deref(), true) + } - core_path.is_file().then_some(core_path) - }) + #[cfg(not(feature = "verge-dev"))] + Ok(source.to_path_buf()) } -#[cfg(target_os = "macos")] -const fn macos_cleanup_translocated_desired_state_shell() -> &'static str { - "for f in '/var/root/.local/state/clash-verge-service/desired-state.json' '/var/lib/clash-verge-service/desired-state.json'; do if [ -f \"$f\" ] && /usr/bin/grep -q AppTranslocation \"$f\"; then backup=\"$f.apptranslocation.bak\"; if [ -e \"$backup\" ]; then backup=\"$f.apptranslocation.$(/bin/date +%s).bak\"; fi; /bin/mv \"$f\" \"$backup\"; fi; done" +fn service_core_path(clash_core: &str, bin_ext: &str) -> Result { + let sibling = current_exe()?.with_file_name(format!("{clash_core}{bin_ext}")); + + #[cfg(all(target_os = "macos", feature = "verge-dev"))] + { + let home = std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + service_core_path_for(&sibling, home.as_deref(), true) + } + + #[cfg(not(all(target_os = "macos", feature = "verge-dev")))] + Ok(sibling) } /// 卸载服务前以 root 清理残留 core 和 IPC 套接字。 @@ -169,11 +579,37 @@ fn escape_osascript_double_quoted_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] fn shell_single_quote(value: &str) -> String { format!("'{}'", value.replace('\'', r"'\''")) } +#[cfg(any(target_os = "macos", test))] +fn macos_install_shell(install_path: &Path, gid: u32) -> String { + let install_quoted = shell_single_quote(&install_path.to_string_lossy()); + format!("cd /; CLASH_VERGE_SERVICE_GID={gid} {install_quoted}") +} + +fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result) -> Result { + #[cfg(feature = "verge-dev")] + { + drop(packaged_path); + let directory = std::env::var_os("CLASH_VERGE_DEV_SERVICE_DIR") + .context("CLASH_VERGE_DEV_SERVICE_DIR is missing from the development session")?; + let directory = PathBuf::from(directory); + if !directory.is_absolute() { + bail!("CLASH_VERGE_DEV_SERVICE_DIR must be an absolute path"); + } + Ok(directory.join(file_name)) + } + + #[cfg(not(feature = "verge-dev"))] + { + let _ = file_name; + packaged_path() + } +} + #[cfg(target_os = "windows")] fn uninstall_service() -> Result<()> { logging!(info, Type::Service, "uninstall service"); @@ -182,8 +618,9 @@ fn uninstall_service() -> Result<()> { use runas::Command as RunasCommand; use std::os::windows::process::CommandExt as _; - let binary_path = dirs::service_path()?; - let uninstall_path = binary_path.with_file_name("clash-verge-service-uninstall.exe"); + let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall.exe", || { + Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe")) + })?; if !uninstall_path.exists() { bail!(format!("uninstaller not found: {uninstall_path:?}")); @@ -215,8 +652,9 @@ fn install_service() -> Result<()> { use runas::Command as RunasCommand; use std::os::windows::process::CommandExt as _; - let binary_path = dirs::service_path()?; - let install_path = binary_path.with_file_name("clash-verge-service-install.exe"); + let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || { + Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe")) + })?; if !install_path.exists() { bail!(format!("installer not found: {install_path:?}")); @@ -257,7 +695,9 @@ fn install_service() -> Result<()> { fn uninstall_service() -> Result<()> { logging!(info, Type::Service, "uninstall service"); - let uninstall_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall"); + let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || { + Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall")) + })?; if !uninstall_path.exists() { bail!(format!("uninstaller not found: {uninstall_path:?}")); @@ -303,7 +743,9 @@ fn uninstall_service() -> Result<()> { fn install_service() -> Result<()> { logging!(info, Type::Service, "install service"); - let install_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install"); + let install_path = packaged_service_tool_path("clash-verge-service-install", || { + Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install")) + })?; if !install_path.exists() { bail!(format!("installer not found: {install_path:?}")); @@ -355,13 +797,15 @@ fn linux_running_as_root() -> bool { fn uninstall_service() -> Result<()> { logging!(info, Type::Service, "uninstall service"); - let binary_path = dirs::service_path()?; - let uninstall_path = binary_path.with_file_name("clash-verge-service-uninstall"); + let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || { + Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall")) + })?; if !uninstall_path.exists() { bail!(format!("uninstaller not found: {uninstall_path:?}")); } + let uninstall_path = macos_service_tool_path(&uninstall_path)?; let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned(); // clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref()); @@ -369,7 +813,7 @@ fn uninstall_service() -> Result<()> { let prompt = clash_verge_i18n::t!("service.adminUninstallPrompt"); // 先清理服务残留,再执行卸载器。 let uninstall_quoted = shell_single_quote(&uninstall_shell); - let shell = format!("{}; sudo {uninstall_quoted}", macos_force_stop_core_shell()); + let shell = format!("cd /; {}; {uninstall_quoted}", macos_force_stop_core_shell()); let shell = escape_osascript_double_quoted_string(&shell); let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#); @@ -391,24 +835,23 @@ fn uninstall_service() -> Result<()> { fn install_service() -> Result<()> { logging!(info, Type::Service, "install service"); - let binary_path = dirs::service_path()?; - let install_path = binary_path.with_file_name("clash-verge-service-install"); + let binary_path = packaged_service_tool_path("clash-verge-service", dirs::service_path)?; + let install_path = packaged_service_tool_path("clash-verge-service-install", || { + Ok(dirs::service_path()?.with_file_name("clash-verge-service-install")) + })?; if !install_path.exists() { bail!(format!("installer not found: {install_path:?}")); } - let install_shell: String = install_path.to_string_lossy().into_owned(); + macos_service_tool_path(&binary_path)?; + let install_path = macos_service_tool_path(&install_path)?; // clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref()); let gid = tauri_plugin_clash_verge_sysinfo::current_gid(); let prompt = clash_verge_i18n::t!("service.adminInstallPrompt"); - let install_quoted = shell_single_quote(&install_shell); - let shell = format!( - "{}; sudo CLASH_VERGE_SERVICE_GID={gid} {install_quoted}", - macos_cleanup_translocated_desired_state_shell() - ); + let shell = macos_install_shell(&install_path, gid); let shell = escape_osascript_double_quoted_string(&shell); let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#); @@ -445,33 +888,45 @@ fn check_output_error(output: &std::process::Output) -> Option<(i32, Cow<'_, str fn reinstall_service() -> Result<()> { logging!(info, Type::Service, "reinstall service"); - - // 先卸载服务 - if let Err(err) = uninstall_service() { - logging!(warn, Type::Service, "failed to uninstall service: {}", err); - } - - // 再安装服务 - match install_service() { - Ok(_) => Ok(()), - Err(err) => { - bail!(format!("failed to install service: {err}")) - } - } + install_service() } /// 强制重装服务(UI修复按钮) fn force_reinstall_service() -> Result<()> { logging!(info, Type::Service, "用户请求强制重装服务"); - reinstall_service().map_err(|err| { + install_service().map_err(|err| { logging!(error, Type::Service, "强制重装服务失败: {}", err); err }) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PrivilegedServiceAction { + Install, + Uninstall, + Reinstall, + ForceReinstall, +} + +const fn privileged_service_action(status: &ServiceStatus) -> Option { + match status { + ServiceStatus::InstallRequired => Some(PrivilegedServiceAction::Install), + ServiceStatus::UninstallRequired => Some(PrivilegedServiceAction::Uninstall), + ServiceStatus::ReinstallRequired => Some(PrivilegedServiceAction::Reinstall), + ServiceStatus::ForceReinstallRequired => Some(PrivilegedServiceAction::ForceReinstall), + ServiceStatus::Checking + | ServiceStatus::Ready + | ServiceStatus::NotInstalled + | ServiceStatus::NeedsReinstall + | ServiceStatus::SidecarAllowed + | ServiceStatus::Unavailable(_) => None, + } +} + /// 尝试使用服务启动core -pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result<()> { +pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> { logging!(info, Type::Service, "尝试使用现有服务启动核心"); + clear_active_service_session(); let verge_config = Config::verge().await; let clash_core = verge_config.latest_arc().get_valid_clash_core(); @@ -480,48 +935,80 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result let bin_ext = if cfg!(windows) { ".exe" } else { "" }; let bin_path = service_core_path(&clash_core, bin_ext)?; - let payload = clash_verge_service_ipc::ClashConfig { - core_config: CoreConfig { - config_path: dirs::path_to_str(config_file)?.into(), - core_path: dirs::path_to_str(&bin_path)?.into(), - core_ipc_path: IClashTemp::guard_external_controller_ipc(), - config_dir: dirs::path_to_str(&dirs::app_home_dir()?)?.into(), - }, - log_config: Logger::global().service_writer_config()?, + let credentials = current_owner_credentials()?; + let runtime = collect_runtime_bundle(config_file, &bin_path).await?; + let proposed_session_token = generate_service_session_token()?; + let request = StartClashRequest { + runtime, + proposed_session_token: proposed_session_token.clone(), + macos_proxy: None, }; - let response = clash_verge_service_ipc::start_clash(&payload) - .await - .context("无法连接到Clash Verge Service")?; + let response = match clash_verge_service_ipc::start_clash(&credentials, &request).await { + Ok(response) => response, + Err(error) => { + start_owner_monitor(); + return Err(error).context("无法连接到Clash Verge Service"); + } + }; if response.code > 0 { let err_msg = response.message; logging!(error, Type::Service, "启动核心失败: {}", err_msg); + start_owner_monitor(); bail!(err_msg); } + let result = response.data.context("Clash Verge Service 未返回会话信息")?; + *ACTIVE_SERVICE_SESSION.lock() = Some(OwnerSessionProof { + generation: result.session.generation, + token: proposed_session_token, + }); + + server::set_pac_available(true); + start_owner_monitor(); logging!(info, Type::Service, "服务成功启动核心"); Ok(()) } // 以服务启动core -pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> { +pub(super) async fn run_core_by_service(config_file: &Path) -> Result<()> { logging!(info, Type::Service, "正在尝试通过服务启动核心"); SERVICE_MANAGER.refresh().await?; + let status = SERVICE_MANAGER.current().await; + if !matches!(status, ServiceStatus::Ready) { + bail!("service is not ready after refresh: {status:?}"); + } + logging!(info, Type::Service, "服务已运行且版本匹配,直接使用"); start_with_existing_service(config_file).await } +async fn capture_generation_before(generation: &AtomicU64, operation: F) -> (u64, T) +where + F: FnOnce() -> Fut, + Fut: Future, +{ + let captured = generation.load(Ordering::Acquire); + (captured, operation().await) +} + pub(super) async fn get_clash_logs_by_service() -> Result> { logging!(info, Type::Service, "正在获取服务模式下的 Clash 日志"); - let response = clash_verge_service_ipc::get_clash_logs() - .await - .context("无法连接到Clash Verge Service")?; + let credentials = current_owner_credentials()?; + let (generation, response) = capture_generation_before(&OWNER_MONITOR_GENERATION, || { + clash_verge_service_ipc::get_clash_logs(&credentials) + }) + .await; + let response = response.context("无法连接到Clash Verge Service")?; if response.code > 0 { + if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 { + recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await; + } let err_msg = response.message; logging!(error, Type::Service, "获取服务模式下的 Clash 日志失败: {}", err_msg); bail!(err_msg); @@ -531,62 +1018,456 @@ pub(super) async fn get_clash_logs_by_service() -> Result> { Ok(response.data.unwrap_or_default()) } +pub(crate) async fn get_clash_log_snapshot_by_service() -> Result { + let credentials = current_owner_credentials()?; + let (generation, response) = capture_generation_before(&OWNER_MONITOR_GENERATION, || { + clash_verge_service_ipc::get_clash_log_snapshot(&credentials) + }) + .await; + let response = response.context("无法连接到Clash Verge Service")?; + if response.code > 0 { + if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 { + recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await; + } + bail!(response.message); + } + let encoded = response.data.context("服务未返回核心日志快照")?; + if encoded.len() % 2 != 0 { + bail!("服务返回了无效的核心日志快照"); + } + let mut content = Vec::with_capacity(encoded.len() / 2); + for offset in (0..encoded.len()).step_by(2) { + content.push(u8::from_str_radix(&encoded[offset..offset + 2], 16).context("服务返回了无效的核心日志快照")?); + } + Ok(String::from_utf8_lossy(&content).into_owned()) +} + /// 通过服务停止core pub(super) async fn stop_core_by_service() -> Result<()> { logging!(info, Type::Service, "通过服务停止核心 (IPC)"); + cancel_owner_monitors(); - let response = clash_verge_service_ipc::stop_clash() - .await - .context("无法连接到Clash Verge Service")?; + let credentials = match current_owner_credentials() { + Ok(credentials) => credentials, + Err(error) => { + start_owner_monitor(); + return Err(error); + } + }; + let session = match active_service_session() { + Ok(session) => session, + Err(error) => { + start_owner_monitor(); + return Err(error); + } + }; + let response = match clash_verge_service_ipc::stop_clash(&credentials, &session).await { + Ok(response) => response, + Err(error) => { + start_owner_monitor(); + return Err(error).context("无法连接到Clash Verge Service"); + } + }; if response.code > 0 { + if matches!( + response.code, + code if code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 + || code == clash_verge_service_ipc::ServiceErrorCode::StaleOwnerSession as u16 + ) { + recover_after_owner_loss_while_locked(OwnerRecoveryReason::Displaced).await; + } else { + start_owner_monitor(); + } let err_msg = response.message; logging!(error, Type::Service, "停止核心失败: {}", err_msg); bail!(err_msg); } + clear_active_service_session(); logging!(info, Type::Service, "服务成功停止核心"); Ok(()) } -/// 检查服务是否正在运行 -pub async fn is_service_available() -> Result<()> { - if let Err(e) = Path::metadata(clash_verge_service_ipc::IPC_PATH.as_ref()) { - let verge = Config::verge().await; - let verge_last = verge.latest_arc(); - let is_enable = verge_last.enable_tun_mode.unwrap_or(false); - if is_enable { - logging!(warn, Type::Service, "Some issue with service IPC Path: {}", e); - } - return Err(e.into()); +pub(crate) async fn update_writer_by_service(writer: &WriterConfig) -> Result<()> { + let credentials = current_owner_credentials()?; + let session = active_service_session()?; + let response = clash_verge_service_ipc::update_writer(&credentials, &session, writer) + .await + .context("无法连接到Clash Verge Service")?; + if response.code > 0 { + bail!(response.message); } - clash_verge_service_ipc::connect().await?; Ok(()) } +pub(super) async fn set_system_proxy_by_service(proxy: &MacosProxyConfig) -> Result { + let session = active_service_session()?; + set_system_proxy_by_service_with_session(proxy, &session).await +} + +pub(super) async fn set_system_proxy_by_service_with_session( + proxy: &MacosProxyConfig, + session: &OwnerSessionProof, +) -> Result { + let credentials = current_owner_credentials()?; + let response = clash_verge_service_ipc::set_system_proxy(&credentials, session, proxy) + .await + .context("无法连接到Clash Verge Service")?; + if response.code > 0 { + bail!(response.message); + } + response.data.context("Clash Verge Service 未返回系统代理结果") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OwnerRecoveryReason { + Displaced, + SameOwnerFailure, + TransportFailure, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct OwnerRecoveryPolicy { + reset_system_proxy: bool, +} + +const fn owner_recovery_policy(_reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy { + OwnerRecoveryPolicy { + reset_system_proxy: !is_macos, + } +} + +fn owner_status_recovery_reason( + is_active: bool, + desired_running: bool, + service_state: clash_verge_service_ipc::ServiceLifecycleState, + core_pid: Option, + missing_core_samples: u8, +) -> Option { + if !is_active { + return Some(OwnerRecoveryReason::Displaced); + } + if !desired_running + || service_state == clash_verge_service_ipc::ServiceLifecycleState::Fatal + || (!matches!( + service_state, + clash_verge_service_ipc::ServiceLifecycleState::Starting + | clash_verge_service_ipc::ServiceLifecycleState::RecoveringCore + ) && core_pid.is_none() + && missing_core_samples >= 3) + { + return Some(OwnerRecoveryReason::SameOwnerFailure); + } + None +} + +const fn transport_failure_recovery_reason( + failed_status_samples: u8, + owner_endpoint_available: bool, +) -> Option { + if failed_status_samples >= 3 && !owner_endpoint_available { + Some(OwnerRecoveryReason::TransportFailure) + } else { + None + } +} + +fn mark_service_unavailable_after_owner_loss(manager: &ServiceManager, reason: OwnerRecoveryReason) { + if matches!(reason, OwnerRecoveryReason::TransportFailure) { + manager.mark_unavailable("service control IPC unavailable after sustained transport failure"); + } +} + +async fn recover_after_sustained_status_failure(generation: u64, failed_status_samples: u8) -> bool { + if failed_status_samples < 3 { + return false; + } + + let owner_endpoint_available = Handle::mihomo().await.get_version().await.is_ok(); + if let Some(reason) = transport_failure_recovery_reason(failed_status_samples, owner_endpoint_available) { + recover_after_owner_loss(generation, reason).await; + true + } else { + false + } +} + +async fn recover_after_status_mismatch_with( + session: &Mutex>, + generation: u64, + is_active: bool, + active_generation: Option, + recover: F, +) -> bool +where + F: FnOnce(u64, OwnerRecoveryReason) -> Fut, + Fut: Future, +{ + let session_matches = session + .lock() + .as_ref() + .is_some_and(|proof| session_matches_status(proof, is_active, active_generation)); + if session_matches { + return false; + } + recover(generation, OwnerRecoveryReason::Displaced).await; + true +} + +fn start_owner_monitor() { + let generation = OWNER_MONITOR_GENERATION.fetch_add(1, Ordering::AcqRel) + 1; + AsyncHandler::spawn(move || async move { + let mut missing_core_samples = 0u8; + let mut failed_status_samples = 0u8; + loop { + tokio::time::sleep(Duration::from_secs(2)).await; + if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation { + break; + } + if !matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) { + break; + } + let response = match current_owner_credentials() { + Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await, + Err(error) => Err(error), + }; + let response = match response { + Ok(response) => response, + Err(error) => { + failed_status_samples = failed_status_samples.saturating_add(1); + if failed_status_samples == 3 { + logging!( + warn, + Type::Service, + "service owner status temporarily unavailable; preserving local proxy state: {error:#}" + ); + } + if recover_after_sustained_status_failure(generation, failed_status_samples).await { + break; + } + if failed_status_samples >= 3 { + failed_status_samples = 0; + } + continue; + } + }; + if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 { + recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await; + break; + } + if response.code != 0 { + failed_status_samples = failed_status_samples.saturating_add(1); + if failed_status_samples == 3 { + logging!( + warn, + Type::Service, + "service owner status returned error {}; preserving local proxy state: {}", + response.code, + response.message + ); + } + if recover_after_sustained_status_failure(generation, failed_status_samples).await { + break; + } + if failed_status_samples >= 3 { + failed_status_samples = 0; + } + continue; + } + let Some(status) = response.data else { + failed_status_samples = failed_status_samples.saturating_add(1); + if failed_status_samples == 3 { + logging!( + warn, + Type::Service, + "service owner status omitted data; preserving local proxy state" + ); + } + if recover_after_sustained_status_failure(generation, failed_status_samples).await { + break; + } + if failed_status_samples >= 3 { + failed_status_samples = 0; + } + continue; + }; + if recover_after_status_mismatch_with( + &ACTIVE_SERVICE_SESSION, + generation, + status.is_active, + status.active_generation, + recover_after_owner_loss, + ) + .await + { + break; + } + failed_status_samples = 0; + missing_core_samples = if status.core_pid.is_none() + && !matches!( + status.service_state, + clash_verge_service_ipc::ServiceLifecycleState::Starting + | clash_verge_service_ipc::ServiceLifecycleState::RecoveringCore + ) { + missing_core_samples.saturating_add(1) + } else { + 0 + }; + if let Some(reason) = owner_status_recovery_reason( + status.is_active, + status.desired_core_should_be_running, + status.service_state, + status.core_pid, + missing_core_samples, + ) { + recover_after_owner_loss(generation, reason).await; + break; + } + } + }); +} + +fn cancel_owner_monitors() { + OWNER_MONITOR_GENERATION.fetch_add(1, Ordering::AcqRel); +} + +pub(crate) fn owner_monitor_generation() -> u64 { + OWNER_MONITOR_GENERATION.load(Ordering::Acquire) +} + +async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason) { + let manager = CoreManager::global(); + if !matches!(*manager.get_running_mode(), RunningMode::Service) { + return; + } + let Some(recovery_generation) = claim_owner_recovery_generation(&OWNER_MONITOR_GENERATION, generation) else { + return; + }; + manager.invalidate_core_readiness(); + let _lifecycle = manager.lifecycle_lock.lock().await; + if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != recovery_generation + || !matches!(*manager.get_running_mode(), RunningMode::Service) + { + return; + } + recover_after_owner_loss_while_locked(reason).await; +} + +fn claim_owner_recovery_generation(generation: &AtomicU64, captured_generation: u64) -> Option { + let recovery_generation = captured_generation.wrapping_add(1); + generation + .compare_exchange( + captured_generation, + recovery_generation, + Ordering::AcqRel, + Ordering::Acquire, + ) + .ok() + .map(|_| recovery_generation) +} + +async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) { + logging!( + warn, + Type::Service, + "service owner recovery ({reason:?}); clearing local proxy and PAC state" + ); + mark_service_unavailable_after_owner_loss(&SERVICE_MANAGER, reason); + proxy_control::stop_guard().await; + server::set_pac_available(false); + clear_active_service_session(); + let manager = CoreManager::global(); + manager.invalidate_core_readiness(); + manager.set_running_mode(RunningMode::NotRunning); + manager.after_core_process(); + + if !owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy { + return; + } + + let mut last_error = None; + for _ in 0..3 { + match proxy_control::clear().await { + Ok(()) => return, + Err(error) => { + last_error = Some(error); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + if let Some(error) = last_error { + logging!( + error, + Type::Service, + "failed to clear local proxy after owner loss: {error}" + ); + } +} + +async fn probe_service_availability() -> Result { + probe_service_availability_with(&SERVICE_MANAGER, true, probe_service_version_once).await +} + +async fn probe_service_availability_with( + manager: &ServiceManager, + require_cached_ready: bool, + probe: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let captured_generation = { + let status = manager.status.lock(); + if manager.operation_running.load(Ordering::Acquire) { + return Ok(false); + } + if require_cached_ready && !matches!(*status, ServiceStatus::Ready) { + return Ok(false); + } + let generation = manager.status_generation.load(Ordering::Acquire); + drop(status); + generation + }; + + let result = probe().await; + let mut status = manager.status.lock(); + if manager.operation_running.load(Ordering::Acquire) + || manager.status_generation.load(Ordering::Acquire) != captured_generation + { + return Ok(false); + } + + manager.status_generation.fetch_add(1, Ordering::AcqRel); + match result { + Ok(reply) => match classify_service_version_reply(&reply) { + ServiceVersionCheck::Ready => { + *status = ServiceStatus::Ready; + Ok(true) + } + ServiceVersionCheck::NeedsReinstall(error) => { + *status = ServiceStatus::NeedsReinstall; + Err(anyhow::anyhow!(error)) + } + }, + Err(error) => { + *status = ServiceStatus::Unavailable(format!("service availability probe failed: {error:#}")); + Err(error).context("service availability probe failed") + } + } +} + +/// Returns true only when the service probe explicitly confirms availability. +pub async fn is_service_available() -> bool { + confirmed_service_available(probe_service_availability().await) +} + async fn wait_for_service_ipc(manager: &ServiceManager) -> Result<()> { let config = ServiceManager::config(); + let result = poll_service_version(config.max_retries, config.retry_delay, probe_service_version_once).await; - let backoff = ConstantBuilder::default() - .with_delay(config.retry_delay) - .with_max_times(config.max_retries); - - let result = (|| async { - if !is_service_ipc_path_exists() { - bail!("IPC path not ready"); - } - clash_verge_service_ipc::connect().await.map(drop) - }) - .retry(backoff) - .await; - - if result.is_ok() { - manager.set_status(ServiceStatus::Ready); - } else { - manager.set_status(ServiceStatus::Unavailable("Waiting for service to be available".into())); - } - - result + apply_service_version_result(manager, result, "service IPC did not become available") } pub fn is_service_ipc_path_exists() -> bool { @@ -602,12 +1483,9 @@ impl ServiceManager { } } + #[cfg(target_os = "windows")] pub async fn init(&self) -> Result<()> { - if let Err(e) = clash_verge_service_ipc::connect().await { - self.set_status(ServiceStatus::Unavailable("服务连接失败: {e}".to_string())); - return Err(e); - } - Ok(()) + apply_service_version_result(self, probe_service_version_once().await, "service connection failed") } pub async fn current(&self) -> ServiceStatus { @@ -623,34 +1501,118 @@ impl ServiceManager { } } - fn set_status(&self, status: ServiceStatus) { - *self.status.lock() = status; + pub async fn install_state(&self) -> ServiceInstallState { + self.current().await.install_state() } - async fn run_operation(&self, operation: impl Future>) -> Result<()> { + pub fn allow_sidecar_for_session(&self) -> Result<()> { + let mut status = self.status.lock(); + if self.operation_running.load(Ordering::Acquire) { + bail!("service operation already running"); + } + if !matches!( + *status, + ServiceStatus::NotInstalled + | ServiceStatus::NeedsReinstall + | ServiceStatus::InstallRequired + | ServiceStatus::Unavailable(_) + ) { + bail!("sidecar cannot be allowed from service status {status:?}"); + } + self.status_generation.fetch_add(1, Ordering::AcqRel); + *status = ServiceStatus::SidecarAllowed; + drop(status); + Ok(()) + } + + pub fn require_install_for_session(&self) -> Result<()> { + let mut status = self.status.lock(); + if self.operation_running.load(Ordering::Acquire) { + bail!("service operation already running"); + } + if matches!(*status, ServiceStatus::NotInstalled) { + self.status_generation.fetch_add(1, Ordering::AcqRel); + *status = ServiceStatus::InstallRequired; + } + drop(status); + Ok(()) + } + + pub(crate) fn mark_unavailable(&self, reason: impl Into) { + self.set_status(ServiceStatus::Unavailable(reason.into())); + } + + pub async fn detect_startup_status(&self) { + if cfg!(feature = "dev-sidecar") { + self.set_status(ServiceStatus::SidecarAllowed); + return; + } + let status = match detect_service_install_state().await { + ServiceInstallState::Checking => ServiceStatus::Checking, + ServiceInstallState::Ready => ServiceStatus::Ready, + ServiceInstallState::NotInstalled => ServiceStatus::NotInstalled, + ServiceInstallState::InstallRequired => ServiceStatus::InstallRequired, + ServiceInstallState::NeedsReinstall => ServiceStatus::NeedsReinstall, + ServiceInstallState::SidecarAllowed => ServiceStatus::SidecarAllowed, + ServiceInstallState::Unavailable => ServiceStatus::Unavailable("service detection failed".into()), + }; + self.set_status(status); + } + + fn set_status(&self, status: ServiceStatus) { + let mut current = self.status.lock(); + self.status_generation.fetch_add(1, Ordering::AcqRel); + *current = status; + } + + async fn run_operation_and_then( + &self, + operation: impl Future>, + post_operation: Post, + ) -> Result<()> + where + Post: FnOnce() -> PostFuture, + PostFuture: Future>, + { { + let _status = self.status.lock(); if self.operation_running.swap(true, Ordering::AcqRel) { bail!("service operation already running"); } - defer! { - self.operation_running.store(false, Ordering::Release); - self.operation_done.notify_waiters(); - } - - operation.await?; + self.status_generation.fetch_add(1, Ordering::AcqRel); } + let operation_completion = scopeguard::guard(self, |manager| { + manager.operation_running.store(false, Ordering::Release); + manager.operation_done.notify_waiters(); + }); - Tray::global().update_menu().await + let result = operation.await; + drop(operation_completion); + result?; + post_operation().await + } + + async fn run_operation(&self, operation: impl Future>) -> Result<()> { + self.run_operation_and_then(operation, || async { + if let Err(error) = Tray::global().update_menu().await { + logging!( + warn, + Type::Service, + "failed to refresh tray after service operation: {error:#}" + ); + } + Ok(()) + }) + .await } pub async fn refresh(&self) -> Result<()> { self.run_operation(async { - self.apply_service_status(if clash_verge_service_ipc::is_reinstall_service_needed().await { - ServiceStatus::NeedsReinstall - } else { - ServiceStatus::Ready - }) - .await + apply_service_version_result( + self, + probe_service_version_once().await, + "failed to refresh service protocol", + ) }) .await } @@ -661,38 +1623,62 @@ impl ServiceManager { async fn apply_service_status(&self, status: ServiceStatus) -> Result<()> { self.set_status(status.clone()); - match status { - ServiceStatus::Ready => logging!(info, Type::Service, "服务就绪,直接启动"), - ServiceStatus::NeedsReinstall | ServiceStatus::ReinstallRequired => { + match privileged_service_action(&status) { + Some(PrivilegedServiceAction::Reinstall) => { logging!(info, Type::Service, "服务需要重装,执行重装流程"); run_service_command(reinstall_service, "reinstall service")?; wait_for_service_ipc(self).await?; + Config::restore_tun_for_session().await; } - ServiceStatus::ForceReinstallRequired => { + Some(PrivilegedServiceAction::ForceReinstall) => { logging!(info, Type::Service, "服务需要强制重装,执行强制重装流程"); run_service_command(force_reinstall_service, "force reinstall service")?; wait_for_service_ipc(self).await?; + Config::restore_tun_for_session().await; } - ServiceStatus::InstallRequired => { + Some(PrivilegedServiceAction::Install) => { logging!(info, Type::Service, "需要安装服务,执行安装流程"); run_service_command(install_service, "install service")?; wait_for_service_ipc(self).await?; - if clash_verge_service_ipc::is_reinstall_service_needed().await { - logging!(info, Type::Service, "服务版本不匹配,执行重装流程"); - self.set_status(ServiceStatus::NeedsReinstall); - run_service_command(reinstall_service, "reinstall service")?; - wait_for_service_ipc(self).await?; - } + Config::restore_tun_for_session().await; } - ServiceStatus::UninstallRequired => { + Some(PrivilegedServiceAction::Uninstall) => { logging!(info, Type::Service, "服务需要卸载,执行卸载流程"); - run_service_command(uninstall_service, "uninstall service")?; - self.set_status(ServiceStatus::Unavailable("Service Uninstalled".into())); - } - ServiceStatus::Unavailable(reason) => { - logging!(info, Type::Service, "服务不可用: {},将使用Sidecar模式", reason); - bail!("服务不可用: {}", reason); + if let Err(error) = run_service_command(uninstall_service, "uninstall service") { + self.set_status(ServiceStatus::Unavailable(format!( + "Service uninstall failed: {error:#}" + ))); + return Err(error); + } + self.set_status(ServiceStatus::NotInstalled); } + None => match status { + ServiceStatus::Checking => bail!("service status is still being checked"), + ServiceStatus::Ready => logging!(info, Type::Service, "服务就绪,直接启动"), + ServiceStatus::NotInstalled => { + logging!(info, Type::Service, "service is not installed; Sidecar is available"); + } + ServiceStatus::NeedsReinstall => { + bail!("service needs reinstall; explicit authorization is required"); + } + ServiceStatus::Unavailable(reason) => { + logging!(info, Type::Service, "服务不可用: {},将使用Sidecar模式", reason); + bail!("服务不可用: {}", reason); + } + ServiceStatus::SidecarAllowed => { + logging!( + info, + Type::Service, + "Sidecar was explicitly allowed for this app session" + ); + } + ServiceStatus::InstallRequired + | ServiceStatus::UninstallRequired + | ServiceStatus::ReinstallRequired + | ServiceStatus::ForceReinstallRequired => { + bail!("invalid privileged service action mapping") + } + }, } Ok(()) @@ -704,60 +1690,677 @@ fn run_service_command(operation: impl FnOnce() -> Result<()>, label: &'static s } pub static SERVICE_MANAGER: Lazy = Lazy::new(|| ServiceManager { - status: Mutex::new(ServiceStatus::Unavailable("Need Checks".into())), + status: Mutex::new(ServiceStatus::Checking), + status_generation: AtomicU64::new(0), operation_running: AtomicBool::new(false), operation_done: Notify::new(), }); -#[cfg(all(test, target_os = "macos"))] +#[cfg(test)] mod tests { - use super::*; - use std::fs; + use super::{ + CurrentServiceProbe, OwnerRecoveryReason, PrivilegedServiceAction, ServiceInstallState, ServiceStatus, + ServiceVersionCheck, ServiceVersionReply, apply_service_version_result, capture_generation_before, + claim_owner_recovery_generation, classify_service_install_state, classify_service_version_reply, + confirmed_service_available, generate_service_session_token, macos_install_shell, + mark_service_unavailable_after_owner_loss, owner_recovery_policy, owner_status_recovery_reason, + poll_service_version, privileged_service_action, probe_service_availability_with, service_core_path_for, + session_matches_status, transport_failure_recovery_reason, + }; + #[cfg(unix)] + use super::{service_core_path_for_with_publisher, service_tool_path_for}; + use clash_verge_service_ipc::{OwnerSessionProof, ProtocolInfo, ProtocolVersion, ServiceLifecycleState}; + #[cfg(unix)] + use std::cell::Cell; + use std::{ + path::{Path, PathBuf}, + sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + time::Duration, + }; - fn test_dir(name: &str) -> std::io::Result { - let path = std::env::temp_dir().join(format!("clash-verge-service-path-test-{}-{name}", std::process::id())); - let _ = fs::remove_dir_all(&path); - fs::create_dir_all(&path)?; - Ok(path) + fn current_protocol() -> ProtocolInfo { + ProtocolInfo::current() + } + + fn incompatible_protocol() -> ProtocolInfo { + let mut info = ProtocolInfo::current(); + info.protocol = ProtocolVersion { + epoch: info.protocol.epoch.saturating_add(1), + revision: info.protocol.revision, + }; + info + } + + static TEST_DIRECTORY_GENERATION: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new(label: &str) -> anyhow::Result { + let generation = TEST_DIRECTORY_GENERATION.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "clash-verge-rev-service-{label}-{}-{generation}", + std::process::id() + )); + std::fs::create_dir(&path)?; + Ok(Self(path)) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn staging_directory(home: &Path) -> PathBuf { + home.join("Applications/.clash-verge-rev-dev/service-core") + } + + #[cfg(unix)] + fn service_tools_staging_directory(home: &Path) -> PathBuf { + home.join("Applications/.clash-verge-rev-dev/service-tools") + } + + #[cfg(unix)] + fn staging_temporary_entries(home: &Path, core_name: &str) -> anyhow::Result> { + let directory = staging_directory(home); + if !directory.exists() { + return Ok(Vec::new()); + } + Ok(std::fs::read_dir(directory)? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(&format!(".{core_name}.")) && name.ends_with(".tmp")) + }) + .collect()) } #[test] - fn detects_app_translocation_paths() { - let path = Path::new( - "/private/var/folders/example/T/AppTranslocation/123/d/Clash Verge.app/Contents/MacOS/Clash Verge", - ); + fn nondevelopment_service_core_selection_preserves_sibling_without_staging() -> anyhow::Result<()> { + let root = TestDirectory::new("release-path")?; + let home = root.path().join("home"); + let source = root.path().join("target/debug/verge-mihomo"); - assert!(is_macos_app_translocated(path)); - } + let selected = service_core_path_for(&source, Some(&home), false)?; - #[test] - fn extracts_app_bundle_name_from_executable_path() { - let path = Path::new("/Applications/Clash Verge.app/Contents/MacOS/Clash Verge"); - - assert_eq!( - macos_app_bundle_name(path).as_deref(), - Some(std::ffi::OsStr::new("Clash Verge.app")) - ); - } - - #[test] - fn resolves_existing_core_path_from_install_roots() -> std::io::Result<()> { - let root = test_dir("resolve-existing-core-path")?; - let core_dir = root.join("Clash Verge.app").join("Contents").join("MacOS"); - let core_path = core_dir.join("verge-mihomo"); - - fs::create_dir_all(&core_dir)?; - fs::write(&core_path, b"")?; - - let resolved = macos_core_path_in_install_roots( - std::ffi::OsStr::new("Clash Verge.app"), - "verge-mihomo", - [root.as_path()], - ); - - assert_eq!(resolved, Some(core_path)); - - fs::remove_dir_all(root)?; + assert_eq!(selected, source); + assert!(!staging_directory(&home).exists()); Ok(()) } + + #[cfg(unix)] + #[test] + fn development_service_core_uses_exact_layout_and_executable_bytes() -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + + let root = TestDirectory::new("development-path")?; + let home = root.path().join("home"); + let source = root.path().join("verge-mihomo"); + std::fs::write(&source, b"development core")?; + + let selected = service_core_path_for(&source, Some(&home), true)?; + + assert_eq!( + selected, + home.join("Applications/.clash-verge-rev-dev/service-core/verge-mihomo") + ); + assert_eq!(std::fs::read(&selected)?, b"development core"); + let metadata = std::fs::symlink_metadata(&selected)?; + assert!(metadata.file_type().is_file()); + assert_ne!(metadata.permissions().mode() & 0o111, 0); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn development_service_tool_uses_safe_layout_and_executable_bytes() -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + + let root = TestDirectory::new("development-service-tool")?; + let home = root.path().join("home"); + let source = root.path().join("clash-verge-service-install"); + std::fs::write(&source, b"development installer")?; + + let selected = service_tool_path_for(&source, Some(&home), true)?; + + assert_eq!( + selected, + service_tools_staging_directory(&home).join("clash-verge-service-install") + ); + assert_eq!(std::fs::read(&selected)?, b"development installer"); + assert_ne!(std::fs::metadata(&selected)?.permissions().mode() & 0o111, 0); + Ok(()) + } + + #[test] + fn macos_install_shell_starts_from_root_without_nested_sudo() { + let shell = macos_install_shell(Path::new("/safe/service-tools/clash-verge-service-install"), 20); + + assert_eq!( + shell, + "cd /; CLASH_VERGE_SERVICE_GID=20 '/safe/service-tools/clash-verge-service-install'" + ); + assert!(!shell.contains("sudo")); + } + + #[cfg(unix)] + #[test] + fn development_service_core_refresh_atomically_replaces_bytes() -> anyhow::Result<()> { + let root = TestDirectory::new("refresh")?; + let home = root.path().join("home"); + let source = root.path().join("verge-mihomo"); + std::fs::write(&source, b"first core")?; + let selected = service_core_path_for(&source, Some(&home), true)?; + assert_eq!( + selected, + home.join("Applications/.clash-verge-rev-dev/service-core/verge-mihomo") + ); + + std::fs::write(&source, b"second core")?; + let refreshed = service_core_path_for(&source, Some(&home), true)?; + + assert_eq!(refreshed, selected); + assert_eq!(std::fs::read(&refreshed)?, b"second core"); + assert!(staging_temporary_entries(&home, "verge-mihomo")?.is_empty()); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn failed_development_refresh_preserves_good_core_and_cleans_temporary_entry() -> anyhow::Result<()> { + let root = TestDirectory::new("failed-refresh")?; + let home = root.path().join("home"); + let source = root.path().join("verge-mihomo"); + std::fs::write(&source, b"known good core")?; + let selected = service_core_path_for(&source, Some(&home), true)?; + + std::fs::write(&source, b"replacement core")?; + let publish_attempted = Cell::new(false); + let result = service_core_path_for_with_publisher( + &source, + Some(&home), + true, + "service-core", + |temporary, final_path| { + publish_attempted.set(true); + assert_ne!(temporary, final_path, "publisher must receive the temporary path"); + assert!(std::fs::symlink_metadata(temporary)?.file_type().is_file()); + assert_eq!(std::fs::read(temporary)?, b"replacement core"); + anyhow::bail!("injected post-creation publish failure") + }, + ); + let error = match result { + Ok(path) => anyhow::bail!("failed publication selected {}", path.display()), + Err(error) => error.to_string(), + }; + + assert!(publish_attempted.get()); + assert!(error.contains("injected post-creation publish failure")); + assert_eq!(std::fs::read(&selected)?, b"known good core"); + assert!(staging_temporary_entries(&home, "verge-mihomo")?.is_empty()); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn development_service_core_replaces_final_symlink_without_following_it() -> anyhow::Result<()> { + use std::os::unix::fs::symlink; + + let root = TestDirectory::new("symlink")?; + let home = root.path().join("home"); + let source = root.path().join("verge-mihomo"); + std::fs::write(&source, b"selected core")?; + let final_path = home.join("Applications/.clash-verge-rev-dev/service-core/verge-mihomo"); + std::fs::create_dir_all(final_path.parent().unwrap_or_else(|| Path::new(".")))?; + let symlink_target = root.path().join("must-not-change"); + std::fs::write(&symlink_target, b"target bytes")?; + symlink(&symlink_target, &final_path)?; + + let selected = service_core_path_for(&source, Some(&home), true)?; + + assert_eq!(selected, final_path); + assert!(std::fs::symlink_metadata(&selected)?.file_type().is_file()); + assert_eq!(std::fs::read(&selected)?, b"selected core"); + assert_eq!(std::fs::read(&symlink_target)?, b"target bytes"); + Ok(()) + } + + #[test] + fn mismatched_active_generation_displaces_local_session() { + let proof = OwnerSessionProof { + generation: 7, + token: "11".repeat(32), + }; + assert!(session_matches_status(&proof, true, Some(7))); + assert!(!session_matches_status(&proof, true, Some(8))); + assert!(!session_matches_status(&proof, false, Some(7))); + } + + #[tokio::test] + async fn stale_monitor_mismatch_preserves_newer_session_proof() { + let generation = AtomicU64::new(8); + let recovery_attempted = AtomicBool::new(false); + let newer_proof = OwnerSessionProof { + generation: 8, + token: "22".repeat(32), + }; + let session = parking_lot::Mutex::new(Some(newer_proof.clone())); + let generation_for_recovery = &generation; + let recovery_attempted_for_recovery = &recovery_attempted; + let session_for_recovery = &session; + + let mismatch_recovered = super::recover_after_status_mismatch_with( + &session, + 7, + true, + Some(7), + |captured_generation, reason| async move { + recovery_attempted_for_recovery.store(true, Ordering::Release); + assert_eq!(reason, OwnerRecoveryReason::Displaced); + if claim_owner_recovery_generation(generation_for_recovery, captured_generation).is_some() { + session_for_recovery.lock().take(); + } + }, + ) + .await; + + assert!(mismatch_recovered); + assert!(recovery_attempted.load(Ordering::Acquire)); + assert_eq!(generation.load(Ordering::Acquire), 8); + assert_eq!(session.lock().as_ref(), Some(&newer_proof)); + } + + #[test] + fn generated_service_session_token_is_lower_hex() -> anyhow::Result<()> { + let token = generate_service_session_token()?; + assert_eq!(token.len(), 64); + assert!( + token + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + ); + Ok(()) + } + + #[test] + fn owner_loss_reason_distinguishes_displacement_from_same_owner_failure() { + assert_eq!( + owner_status_recovery_reason(false, true, ServiceLifecycleState::Running, Some(42), 0), + Some(OwnerRecoveryReason::Displaced) + ); + assert_eq!( + owner_status_recovery_reason(true, false, ServiceLifecycleState::Running, None, 1), + Some(OwnerRecoveryReason::SameOwnerFailure) + ); + assert_eq!( + owner_status_recovery_reason(true, true, ServiceLifecycleState::Running, None, 2), + None + ); + assert_eq!( + owner_status_recovery_reason(true, true, ServiceLifecycleState::Running, None, 3), + Some(OwnerRecoveryReason::SameOwnerFailure) + ); + assert_eq!( + owner_status_recovery_reason(true, true, ServiceLifecycleState::RecoveringCore, None, u8::MAX), + None + ); + assert_eq!( + owner_status_recovery_reason(true, true, ServiceLifecycleState::Fatal, None, 0), + Some(OwnerRecoveryReason::SameOwnerFailure) + ); + assert_eq!( + owner_status_recovery_reason(true, true, ServiceLifecycleState::Running, Some(42), 3), + None + ); + } + + #[test] + fn macos_recovery_never_resets_machine_wide_proxy() { + for reason in [ + OwnerRecoveryReason::Displaced, + OwnerRecoveryReason::SameOwnerFailure, + OwnerRecoveryReason::TransportFailure, + ] { + assert!(!owner_recovery_policy(reason, true).reset_system_proxy); + assert!(owner_recovery_policy(reason, false).reset_system_proxy); + } + + let generation = AtomicU64::new(7); + assert_eq!(claim_owner_recovery_generation(&generation, 7), Some(8)); + assert_eq!(generation.load(Ordering::Acquire), 8); + assert_eq!(claim_owner_recovery_generation(&generation, 7), None); + } + + #[test] + fn service_version_requires_a_compatible_protocol_reply() { + let exact = ServiceVersionReply { + code: 0, + message: String::new(), + protocol: Some(current_protocol()), + }; + assert_eq!(classify_service_version_reply(&exact), ServiceVersionCheck::Ready); + + for reply in [ + ServiceVersionReply { + code: 0, + message: String::new(), + protocol: Some(incompatible_protocol()), + }, + ServiceVersionReply { + code: 0, + message: String::new(), + protocol: None, + }, + ServiceVersionReply { + code: 1, + message: "unsupported command".to_owned(), + protocol: Some(current_protocol()), + }, + ] { + let check = classify_service_version_reply(&reply); + assert!(matches!(check, ServiceVersionCheck::NeedsReinstall(_))); + if let ServiceVersionCheck::NeedsReinstall(error) = check { + assert!(error.contains("epoch") || error.contains("protocol")); + assert!(error.contains("Reinstall") || error.contains("Repair")); + } + } + } + + #[test] + fn service_version_result_sets_only_safe_terminal_statuses() { + let manager = super::ServiceManager { + status: parking_lot::Mutex::new(ServiceStatus::Checking), + status_generation: AtomicU64::new(0), + operation_running: std::sync::atomic::AtomicBool::new(false), + operation_done: tokio::sync::Notify::new(), + }; + let exact = ServiceVersionReply { + code: 0, + message: String::new(), + protocol: Some(current_protocol()), + }; + assert!(apply_service_version_result(&manager, Ok(exact), "probe failed").is_ok()); + assert_eq!(*manager.status.lock(), ServiceStatus::Ready); + + let mismatch = ServiceVersionReply { + code: 0, + message: String::new(), + protocol: Some(incompatible_protocol()), + }; + assert!(apply_service_version_result(&manager, Ok(mismatch), "probe failed").is_err()); + assert_eq!(*manager.status.lock(), ServiceStatus::NeedsReinstall); + + assert!( + apply_service_version_result(&manager, Err(anyhow::anyhow!("IPC path missing")), "probe failed").is_err() + ); + assert!(matches!(*manager.status.lock(), ServiceStatus::Unavailable(_))); + } + + #[tokio::test] + async fn service_version_poll_retries_transport_then_returns_exact_reply() { + let attempts = AtomicUsize::new(0); + let result = poll_service_version(2, Duration::ZERO, || async { + if attempts.fetch_add(1, Ordering::AcqRel) == 0 { + anyhow::bail!("IPC path not ready"); + } + Ok(ServiceVersionReply { + code: 0, + message: String::new(), + protocol: Some(current_protocol()), + }) + }) + .await; + + assert_eq!(attempts.load(Ordering::Acquire), 2); + assert!(result.is_ok()); + let reply = result.unwrap_or_else(|_| ServiceVersionReply { + code: 1, + message: "probe failed".to_owned(), + protocol: None, + }); + assert_eq!(classify_service_version_reply(&reply), ServiceVersionCheck::Ready); + } + + #[tokio::test] + async fn stale_availability_probe_cannot_overwrite_a_newer_terminal_status() { + let manager = super::ServiceManager { + status: parking_lot::Mutex::new(ServiceStatus::Ready), + status_generation: AtomicU64::new(0), + operation_running: std::sync::atomic::AtomicBool::new(false), + operation_done: tokio::sync::Notify::new(), + }; + + let available = probe_service_availability_with(&manager, false, || async { + manager.set_status(ServiceStatus::NotInstalled); + Ok(ServiceVersionReply { + code: 0, + message: String::new(), + protocol: Some(current_protocol()), + }) + }) + .await; + + assert!(matches!(available, Ok(false))); + assert_eq!(*manager.status.lock(), ServiceStatus::NotInstalled); + } + + #[tokio::test] + async fn cached_non_ready_availability_gate_and_generation_are_captured_together() { + let manager = super::ServiceManager { + status: parking_lot::Mutex::new(ServiceStatus::NotInstalled), + status_generation: AtomicU64::new(4), + operation_running: AtomicBool::new(false), + operation_done: tokio::sync::Notify::new(), + }; + let probed = AtomicBool::new(false); + + let available = probe_service_availability_with(&manager, true, || async { + probed.store(true, Ordering::Release); + Ok(ServiceVersionReply { + code: 0, + message: String::new(), + protocol: Some(current_protocol()), + }) + }) + .await; + + assert!(matches!(available, Ok(false))); + assert!(!probed.load(Ordering::Acquire)); + assert_eq!(*manager.status.lock(), ServiceStatus::NotInstalled); + assert_eq!(manager.status_generation.load(Ordering::Acquire), 4); + } + + #[tokio::test] + async fn service_operation_finishes_before_post_operation_refresh() { + let manager = super::ServiceManager { + status: parking_lot::Mutex::new(ServiceStatus::Checking), + status_generation: AtomicU64::new(0), + operation_running: AtomicBool::new(false), + operation_done: tokio::sync::Notify::new(), + }; + + let result = manager + .run_operation_and_then( + async { + manager.set_status(ServiceStatus::Ready); + Ok(()) + }, + || async { + assert!(!manager.operation_running.load(Ordering::Acquire)); + assert_eq!(manager.current().await, ServiceStatus::Ready); + Ok(()) + }, + ) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn owner_generation_is_captured_before_async_request_runs() { + let generation = AtomicU64::new(7); + let (captured, response) = capture_generation_before(&generation, || async { + generation.store(8, Ordering::Release); + "not-active" + }) + .await; + + assert_eq!(captured, 7); + assert_eq!(response, "not-active"); + assert_eq!(generation.load(Ordering::Acquire), 8); + } + + #[test] + fn sustained_transport_failure_recovers_only_when_owner_endpoint_is_also_unavailable() { + assert_eq!(transport_failure_recovery_reason(2, false), None); + assert_eq!(transport_failure_recovery_reason(3, true), None); + assert_eq!( + transport_failure_recovery_reason(3, false), + Some(OwnerRecoveryReason::TransportFailure) + ); + } + + #[tokio::test] + async fn only_transport_owner_loss_marks_service_unavailable_and_blocks_stale_probe() { + for reason in [OwnerRecoveryReason::Displaced, OwnerRecoveryReason::SameOwnerFailure] { + let manager = super::ServiceManager { + status: parking_lot::Mutex::new(ServiceStatus::Ready), + status_generation: AtomicU64::new(0), + operation_running: AtomicBool::new(false), + operation_done: tokio::sync::Notify::new(), + }; + mark_service_unavailable_after_owner_loss(&manager, reason); + assert_eq!(*manager.status.lock(), ServiceStatus::Ready); + assert_eq!(manager.status_generation.load(Ordering::Acquire), 0); + } + + let manager = super::ServiceManager { + status: parking_lot::Mutex::new(ServiceStatus::Ready), + status_generation: AtomicU64::new(0), + operation_running: AtomicBool::new(false), + operation_done: tokio::sync::Notify::new(), + }; + let available = probe_service_availability_with(&manager, false, || async { + mark_service_unavailable_after_owner_loss(&manager, OwnerRecoveryReason::TransportFailure); + Ok(ServiceVersionReply { + code: 0, + message: String::new(), + protocol: Some(current_protocol()), + }) + }) + .await; + + assert!(matches!(available, Ok(false))); + let status = manager.status.lock().clone(); + assert!(matches!( + status, + ServiceStatus::Unavailable(reason) if reason.contains("service control IPC unavailable") + )); + } + + #[test] + fn macos_service_evidence_distinguishes_missing_old_and_current_service() { + assert_eq!( + classify_service_install_state(CurrentServiceProbe::Missing, false), + ServiceInstallState::NotInstalled + ); + assert_eq!( + classify_service_install_state(CurrentServiceProbe::Missing, true), + ServiceInstallState::NeedsReinstall + ); + assert_eq!( + classify_service_install_state(CurrentServiceProbe::Ready, true), + ServiceInstallState::Ready + ); + assert_eq!( + classify_service_install_state(CurrentServiceProbe::VersionMismatch, false), + ServiceInstallState::NeedsReinstall + ); + assert_eq!( + classify_service_install_state(CurrentServiceProbe::Unavailable, false), + ServiceInstallState::Unavailable + ); + assert_eq!( + classify_service_install_state(CurrentServiceProbe::Unavailable, true), + ServiceInstallState::NeedsReinstall + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn legacy_socket_alone_is_not_install_evidence() { + assert!( + !super::macos_service_install_markers() + .iter() + .any(|marker| marker == "/tmp/verge/clash-verge-service.sock") + ); + assert_eq!( + classify_service_install_state(CurrentServiceProbe::Missing, false), + ServiceInstallState::NotInstalled + ); + } + + #[test] + fn only_confirmed_true_service_probe_is_available() { + assert!(confirmed_service_available(Ok::(true))); + assert!(!confirmed_service_available(Ok::(false))); + assert!(!confirmed_service_available(Err::("offline"))); + } + + #[test] + fn checking_is_public_and_unavailable() { + assert_eq!(ServiceStatus::Checking.install_state(), ServiceInstallState::Checking); + assert_ne!(ServiceInstallState::Checking, ServiceInstallState::Ready); + } + + #[test] + fn failed_install_status_can_be_replaced_with_sidecar_allowance() { + let manager = super::ServiceManager { + status: parking_lot::Mutex::new(ServiceStatus::InstallRequired), + status_generation: AtomicU64::new(0), + operation_running: AtomicBool::new(false), + operation_done: tokio::sync::Notify::new(), + }; + + assert!(manager.allow_sidecar_for_session().is_ok()); + assert_eq!(*manager.status.lock(), ServiceStatus::SidecarAllowed); + assert_eq!(manager.status_generation.load(Ordering::Acquire), 1); + } + + #[test] + fn only_explicit_action_states_map_to_privileged_operations() { + assert_eq!(privileged_service_action(&ServiceStatus::Checking), None); + assert_eq!(privileged_service_action(&ServiceStatus::Ready), None); + assert_eq!(privileged_service_action(&ServiceStatus::NotInstalled), None); + assert_eq!(privileged_service_action(&ServiceStatus::NeedsReinstall), None); + assert_eq!(privileged_service_action(&ServiceStatus::SidecarAllowed), None); + assert_eq!( + privileged_service_action(&ServiceStatus::Unavailable("offline".into())), + None + ); + assert_eq!( + privileged_service_action(&ServiceStatus::InstallRequired), + Some(PrivilegedServiceAction::Install) + ); + assert_eq!( + privileged_service_action(&ServiceStatus::UninstallRequired), + Some(PrivilegedServiceAction::Uninstall) + ); + assert_eq!( + privileged_service_action(&ServiceStatus::ReinstallRequired), + Some(PrivilegedServiceAction::Reinstall) + ); + assert_eq!( + privileged_service_action(&ServiceStatus::ForceReinstallRequired), + Some(PrivilegedServiceAction::ForceReinstall) + ); + } } diff --git a/src-tauri/src/core/sysopt.rs b/src-tauri/src/core/sysopt.rs index 109c4b03c..513e90ce1 100644 --- a/src-tauri/src/core/sysopt.rs +++ b/src-tauri/src/core/sysopt.rs @@ -1,7 +1,4 @@ -use crate::{ - config::{Config, IVerge}, - singleton, -}; +use crate::{config::Config, singleton, utils::server}; use anyhow::Result; use clash_verge_logging::{Type, logging}; use parking_lot::RwLock; @@ -33,8 +30,9 @@ const fn proxy_apply_steps(sys_enabled: bool, auto_enabled: bool) -> [ProxyApply } } -pub struct Sysopt { +pub(crate) struct Sysopt { update_lock: TokioMutex<()>, + guard_operation_lock: TokioMutex<()>, reset_sysproxy: AtomicBool, inner_proxy: Arc>, guard: Arc>, @@ -44,6 +42,7 @@ impl Default for Sysopt { fn default() -> Self { Self { update_lock: TokioMutex::new(()), + guard_operation_lock: TokioMutex::new(()), reset_sysproxy: AtomicBool::new(false), inner_proxy: Arc::new(RwLock::new((Sysproxy::default(), Autoproxy::default()))), guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))), @@ -84,17 +83,35 @@ impl Sysopt { Arc::clone(&self.guard) } - pub async fn refresh_guard(&self) { + async fn stop_proxy_guard_locked(&self) { + loop { + let state = self.access_guard().read().get_state(); + if state.is_pendding() { + tokio::task::yield_now().await; + continue; + } + self.access_guard().write().stop(); + return; + } + } + + pub(super) async fn stop_proxy_guard(&self) { + let _operation = self.guard_operation_lock.lock().await; + self.stop_proxy_guard_locked().await; + } + + pub(super) async fn refresh_guard(&self) { logging!(info, Type::Core, "Refreshing system proxy guard..."); let verge = Config::verge().await.latest_arc(); + let _operation = self.guard_operation_lock.lock().await; if !verge.enable_system_proxy.unwrap_or_default() { logging!(info, Type::Core, "System proxy is disabled."); - self.access_guard().write().stop(); + self.stop_proxy_guard_locked().await; return; } if !verge.enable_proxy_guard.unwrap_or_default() { logging!(info, Type::Core, "System proxy guard is disabled."); - self.access_guard().write().stop(); + self.stop_proxy_guard_locked().await; return; } logging!( @@ -114,26 +131,28 @@ impl Sysopt { let guard = self.access_guard(); guard.write().start(); } + while self.access_guard().read().get_state().is_pendding() { + tokio::task::yield_now().await; + } } /// Wait for any in-progress `update_sysproxy` to finish, so that a /// subsequent read of OS-level sysproxy state sees a fully applied /// configuration instead of a partially-applied one (e.g. SOCKS already /// disabled but HTTP still enabled mid-transition). - pub async fn wait_idle(&self) { + pub(crate) async fn wait_idle(&self) { let _ = self.update_lock.lock().await; } /// init the sysproxy - pub async fn update_sysproxy(&self) -> Result<()> { + pub(super) async fn update_sysproxy(&self) -> Result<()> { let _lock = self.update_lock.lock().await; - let verge = Config::verge().await.latest_arc(); let port = match verge.verge_mixed_port { Some(port) => port, None => Config::clash().await.latest_arc().get_mixed_port(), }; - let pac_port = IVerge::get_singleton_port(); + let pac_port = server::embedded_server_port()?; // 先 await, 避免持有锁导致的 Send 问题 let bypass = get_bypass().await; @@ -178,6 +197,7 @@ impl Sysopt { (sys.clone(), auto.clone(), guard_type) }; + let _guard_operation = self.guard_operation_lock.lock().await; self.access_guard().write().set_guard_type(guard_type); let apply_steps = proxy_apply_steps(sys.enable, auto.enable); @@ -197,7 +217,7 @@ impl Sysopt { } /// reset the sysproxy - pub async fn reset_sysproxy(&self) -> Result<()> { + pub(super) async fn reset_sysproxy(&self) -> Result<()> { if self .reset_sysproxy .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) @@ -208,9 +228,9 @@ impl Sysopt { defer! { self.reset_sysproxy.store(false, Ordering::SeqCst); } - - // close proxy guard - self.access_guard().write().set_guard_type(GuardType::None); + let _lock = self.update_lock.lock().await; + let _guard_operation = self.guard_operation_lock.lock().await; + self.stop_proxy_guard_locked().await; // 直接关闭所有代理 let (sys, auto) = { diff --git a/src-tauri/src/core/timer.rs b/src-tauri/src/core/timer.rs index 7a86f17ab..f789978cf 100644 --- a/src-tauri/src/core/timer.rs +++ b/src-tauri/src/core/timer.rs @@ -36,6 +36,24 @@ struct TaskState { running: bool, } +async fn run_timer_profile_update_transition( + update_started: Started, + update_profile: Update, + update_finished: Finished, + update_completed: Completed, +) where + Started: FnOnce(), + Update: FnOnce() -> UpdateFuture, + UpdateFuture: std::future::Future, + Finished: FnOnce(Output), + Completed: FnOnce(), +{ + update_started(); + let result = update_profile().await; + update_finished(result); + update_completed(); +} + impl TaskState { const fn new(key: Key, interval_minutes: u64) -> Self { Self { @@ -386,36 +404,35 @@ impl Timer { let task_start = std::time::Instant::now(); logging!(debug, Type::Timer, "Running timer task for profile: {}", uid); - match tokio::time::timeout(std::time::Duration::from_secs(40), async { - Self::emit_update_event(uid, true); - - let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid); - logging!( - debug, - Type::Timer, - "Profile {} is current active profile: {}", - uid, - is_current - ); - - feat::update_profile(uid, None, is_current, false, false).await - }) - .await - { - Ok(Ok(_)) => { + run_timer_profile_update_transition( + || Self::emit_update_event(uid, true), + || async { + let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid); logging!( - info, + debug, Type::Timer, - "Timer task completed for uid: {} (took {}ms)", + "Profile {} is current active profile: {}", uid, - task_start.elapsed().as_millis() + is_current ); - } - Ok(Err(e)) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e), - Err(_) => logging_error!(Type::Timer, "Timer task timed out for uid: {}", uid), - } - Self::emit_update_event(uid, false); + feat::update_profile(uid, None, is_current, false, false).await + }, + |result| match result { + Ok(_) => { + logging!( + info, + Type::Timer, + "Timer task completed for uid: {} (took {}ms)", + uid, + task_start.elapsed().as_millis() + ); + } + Err(e) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e), + }, + || Self::emit_update_event(uid, false), + ) + .await; } async fn wait_until_resolve_done(max_wait: Duration) { @@ -431,8 +448,31 @@ impl Timer { #[cfg(test)] mod tests { - use super::Timer; + use super::{Timer, run_timer_profile_update_transition}; use crate::config::{PrfItem, PrfOption}; + use parking_lot::Mutex; + use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + task::Poll, + time::Duration, + }; + use tokio::sync::Barrier; + + struct CancellationProbe { + cancelled: Arc, + completed: Arc, + } + + impl Drop for CancellationProbe { + fn drop(&mut self) { + if !self.completed.load(Ordering::Acquire) { + self.cancelled.store(true, Ordering::Release); + } + } + } fn remote_profile(uid: &str, allow_auto_update: Option, update_interval: Option) -> PrfItem { PrfItem { @@ -463,4 +503,65 @@ mod tests { assert_eq!(map.get("enabled"), Some(&30)); assert_eq!(map.get("missing-flag"), Some(&30)); } + + #[tokio::test(start_paused = true)] + async fn timer_profile_update_runs_past_former_deadline_and_pairs_events() -> anyhow::Result<()> { + let calls = Arc::new(Mutex::new(Vec::new())); + let update_started = Arc::new(Barrier::new(2)); + let release_update = Arc::new(Barrier::new(2)); + let update_cancelled = Arc::new(AtomicBool::new(false)); + let update_completed = Arc::new(AtomicBool::new(false)); + + let mut update = Box::pin(run_timer_profile_update_transition( + { + let calls = Arc::clone(&calls); + move || calls.lock().push("update-started") + }, + { + let update_started = Arc::clone(&update_started); + let release_update = Arc::clone(&release_update); + let update_cancelled = Arc::clone(&update_cancelled); + let update_completed = Arc::clone(&update_completed); + move || async move { + let _probe = CancellationProbe { + cancelled: update_cancelled, + completed: Arc::clone(&update_completed), + }; + update_started.wait().await; + release_update.wait().await; + update_completed.store(true, Ordering::Release); + Ok::<(), anyhow::Error>(()) + } + }, + { + let calls = Arc::clone(&calls); + move |result: anyhow::Result<()>| { + assert!(result.is_ok()); + calls.lock().push("terminal-result"); + } + }, + { + let calls = Arc::clone(&calls); + move || calls.lock().push("update-completed") + }, + )); + + assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending)); + update_started.wait().await; + tokio::time::advance(Duration::from_secs(41)).await; + + assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending)); + assert!(!update_cancelled.load(Ordering::Acquire)); + assert_eq!(&*calls.lock(), &["update-started"]); + + release_update.wait().await; + update.await; + assert!(update_completed.load(Ordering::Acquire)); + assert!(!update_cancelled.load(Ordering::Acquire)); + assert_eq!( + &*calls.lock(), + &["update-started", "terminal-result", "update-completed"] + ); + Ok(()) + } } diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 6981e83c7..6c2bf1dde 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -208,8 +208,7 @@ impl Tray { let verge = Config::verge().await.latest_arc(); let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false); let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false); - let tun_mode_available = - is_current_app_handle_admin(app_handle) || service::is_service_available().await.is_ok(); + let tun_mode_available = is_current_app_handle_admin(app_handle) || service::is_service_available().await; let mode = { Config::clash() .await @@ -994,7 +993,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) { let _ = help::open_app_latest_log(); } MenuIds::CORE_LOG => { - let _ = help::open_core_latest_log(); + let _ = help::open_core_latest_log().await; } MenuIds::RESTART_CLASH => feat::restart_clash_core().await, MenuIds::RESTART_APP => feat::restart_app().await, diff --git a/src-tauri/src/enhance/mod.rs b/src-tauri/src/enhance/mod.rs index 321930850..c1ef11713 100644 --- a/src-tauri/src/enhance/mod.rs +++ b/src-tauri/src/enhance/mod.rs @@ -121,7 +121,7 @@ async fn get_config_values() -> ConfigValues { let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = ( Some(verge_arc.get_valid_clash_core()), - enable_tun_mode.unwrap_or(false), + enable_tun_mode.unwrap_or(false) && !Config::tun_suppressed_for_session(), enable_builtin_enhanced.unwrap_or(true), verge_socks_enabled.unwrap_or(false), verge_http_enabled.unwrap_or(false), diff --git a/src-tauri/src/feat/clash.rs b/src-tauri/src/feat/clash.rs index c9672e25c..0a4a8cd44 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -43,7 +43,6 @@ pub async fn restart_app() { // 设置退出标志 handle::Handle::global().set_is_exiting(); - utils::server::shutdown_embedded_server(); Config::apply_all_and_save_file().await; logging!(info, Type::System, "开始异步清理资源"); @@ -53,9 +52,19 @@ pub async fn restart_app() { info, Type::System, "资源清理完成,退出代码: {}", - if cleanup_result { 0 } else { 1 } + if cleanup_result.all_success { 0 } else { 1 } ); + if !cleanup_result.core_stopped { + handle::Handle::global().clear_is_exiting(); + handle::Handle::notice_message( + "set_config::error", + "Failed to stop the core safely; restart was cancelled", + ); + return; + } + + utils::server::shutdown_embedded_server(); let app_handle = handle::Handle::app_handle(); app_handle.restart(); } diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index ef7b49d8b..88bb1bc32 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -1,6 +1,6 @@ use crate::{ config::{Config, IVerge}, - core::{CoreManager, autostart, handle, hotkey, logger::Logger, sysopt, tray}, + core::{CoreManager, autostart, handle, hotkey, logger::Logger, tray}, module::{auto_backup::AutoBackupManager, lightweight}, }; use anyhow::Result; @@ -223,8 +223,9 @@ async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) -> clash_verge_i18n::set_locale(language.as_str()); } if update_flags.contains(UpdateFlags::SYS_PROXY) { - sysopt::Sysopt::global().update_sysproxy().await?; - sysopt::Sysopt::global().refresh_guard().await; + let manager = CoreManager::global(); + let _lifecycle = manager.lifecycle_lock.lock().await; + manager.apply_proxy_after_start().await?; } if update_flags.contains(UpdateFlags::HOTKEY) && let Some(hotkeys) = &patch.hotkeys diff --git a/src-tauri/src/feat/window.rs b/src-tauri/src/feat/window.rs index 5e215ba46..7c5cf4f6b 100644 --- a/src-tauri/src/feat/window.rs +++ b/src-tauri/src/feat/window.rs @@ -1,10 +1,103 @@ use crate::config::Config; -use crate::core::{CoreManager, handle, sysopt}; +use crate::core::{CoreManager, handle}; use crate::module::lightweight; use crate::utils; use crate::utils::window_manager::WindowManager; use clash_verge_logging::{Type, logging}; -use tokio::time::{Duration, timeout}; +use tokio::time::Duration; +#[cfg(target_os = "macos")] +use tokio::time::timeout; + +#[derive(Debug, Clone, Copy)] +pub struct CleanupResult { + pub all_success: bool, + pub core_stopped: bool, +} + +const fn should_abort_exit_after_cleanup(core_stopped: bool) -> bool { + !core_stopped +} + +async fn run_exit_cleanup_transition( + stop_core: Stop, + ancillary_cleanup: Ancillary, +) -> CleanupResult +where + Stop: FnOnce() -> StopFuture, + StopFuture: std::future::Future, + Ancillary: FnOnce() -> AncillaryFuture, + AncillaryFuture: std::future::Future, +{ + if !stop_core().await { + return CleanupResult { + all_success: false, + core_stopped: false, + }; + } + CleanupResult { + all_success: ancillary_cleanup().await, + core_stopped: true, + } +} + +async fn run_interactive_cleanup_transition( + stop_core: Stop, + ancillary_cleanup: Ancillary, +) -> CleanupResult +where + Stop: FnOnce() -> StopFuture, + StopFuture: std::future::Future, + Ancillary: FnOnce() -> AncillaryFuture, + AncillaryFuture: std::future::Future, +{ + run_exit_cleanup_transition(stop_core, ancillary_cleanup).await +} + +async fn run_session_ending_cleanup_transition( + stop_core: Stop, + stop_deadline: DeadlineFuture, + ancillary_cleanup: Ancillary, +) -> CleanupResult +where + Stop: FnOnce() -> StopFuture, + StopFuture: std::future::Future, + DeadlineFuture: std::future::Future, + Ancillary: FnOnce() -> AncillaryFuture, + AncillaryFuture: std::future::Future, +{ + run_exit_cleanup_transition( + || async { + tokio::select! { + biased; + stopped = stop_core() => stopped, + () = stop_deadline => false, + } + }, + ancillary_cleanup, + ) + .await +} + +async fn restore_dns_after_core_stop() -> bool { + #[cfg(target_os = "macos")] + match timeout( + Duration::from_millis(1000), + crate::utils::resolve::dns::restore_public_dns(), + ) + .await + { + Ok(_) => { + logging!(info, Type::Window, "DNS设置已恢复"); + true + } + Err(_) => { + logging!(warn, Type::Window, "Warning: 恢复DNS设置超时"); + false + } + } + #[cfg(not(target_os = "macos"))] + true +} pub async fn open_or_close_dashboard() { if lightweight::is_in_lightweight_mode() { @@ -16,12 +109,11 @@ pub async fn open_or_close_dashboard() { logging!(info, Type::Window, "Window toggle result: {result:?}"); } -pub async fn quit() { +pub async fn quit() -> clash_verge_signal::ShutdownOutcome { logging!(debug, Type::System, "启动退出流程"); // 设置退出标志 handle::Handle::global().set_is_exiting(); - utils::server::shutdown_embedded_server(); Config::apply_all_and_save_file().await; logging!(info, Type::System, "开始异步清理资源"); @@ -31,135 +123,116 @@ pub async fn quit() { info, Type::System, "资源清理完成,退出代码: {}", - if cleanup_result { 0 } else { 1 } + if cleanup_result.all_success { 0 } else { 1 } ); + if should_abort_exit_after_cleanup(cleanup_result.core_stopped) { + handle::Handle::global().clear_is_exiting(); + handle::Handle::notice_message( + "set_config::error", + "Failed to stop the core safely; quit was cancelled", + ); + return clash_verge_signal::ShutdownOutcome::Canceled; + } + + utils::server::shutdown_embedded_server(); let app_handle = handle::Handle::app_handle(); - app_handle.exit(if cleanup_result { 0 } else { 1 }); + app_handle.exit(if cleanup_result.all_success { 0 } else { 1 }); + clash_verge_signal::ShutdownOutcome::Committed } -pub async fn clean_async() -> bool { - logging!(info, Type::System, "开始执行异步清理操作..."); +pub async fn clean_async() -> CleanupResult { + logging!( + info, + Type::System, + "Starting interactive cleanup; controlled core stop will be awaited to completion" + ); - // 重置系统代理 - let proxy_task = tokio::task::spawn(async { - let sys_proxy_enabled = Config::verge().await.data_arc().enable_system_proxy.unwrap_or(false); - if !sys_proxy_enabled { - logging!(info, Type::Window, "系统代理未启用,跳过重置"); - return true; - } - - logging!(info, Type::Window, "开始重置系统代理..."); - match timeout(Duration::from_millis(1500), sysopt::Sysopt::global().reset_sysproxy()).await { - Ok(Ok(_)) => { - logging!(info, Type::Window, "系统代理已重置"); - true - } - Ok(Err(e)) => { - logging!(warn, Type::Window, "Warning: 重置系统代理失败: {e}"); - false - } - Err(_) => { - logging!(warn, Type::Window, "Warning: 重置系统代理超时,继续退出"); - false - } - } - }); - - // 关闭 Tun 模式 + 停止核心服务 - let core_task = tokio::task::spawn(async { - logging!(info, Type::System, "disable tun"); - let tun_enabled = Config::verge().await.data_arc().enable_tun_mode.unwrap_or(false); - if tun_enabled { - let disable_tun = serde_json::json!({ "tun": { "enable": false } }); - - logging!(info, Type::System, "send disable tun request to mihomo"); - match timeout( - Duration::from_millis(1000), - handle::Handle::mihomo().await.patch_base_config(&disable_tun), - ) - .await - { - Ok(Ok(_)) => { - logging!(info, Type::Window, "TUN模式已禁用"); + let result = run_interactive_cleanup_transition( + || async { + logging!(info, Type::System, "Stopping core for interactive quit or restart"); + match CoreManager::global().stop_core().await { + Ok(()) => { + logging!(info, Type::Window, "Core stopped for interactive quit or restart"); + true } - Ok(Err(e)) => { - logging!(warn, Type::Window, "Warning: 禁用TUN模式失败: {e}"); - } - Err(_) => { + Err(error) => { logging!( warn, Type::Window, - "Warning: 禁用TUN模式超时(可能系统正在关机),继续退出流程" + "Controlled core stop failed; interactive quit or restart must remain cancelled: {error:#}" ); + false } } - } - - #[cfg(target_os = "windows")] - let stop_timeout = Duration::from_secs(2); - #[cfg(not(target_os = "windows"))] - let stop_timeout = Duration::from_secs(3); - - logging!(info, Type::System, "stop core"); - match timeout(stop_timeout, CoreManager::global().stop_core()).await { - Ok(_) => { - logging!(info, Type::Window, "core已停止"); - true - } - Err(_) => { - logging!( - warn, - Type::Window, - "Warning: 停止core超时(可能系统正在关机),继续退出" - ); - false - } - } - }); - - // DNS恢复(仅macOS) - let dns_task = tokio::task::spawn(async { - #[cfg(target_os = "macos")] - match timeout( - Duration::from_millis(1000), - crate::utils::resolve::dns::restore_public_dns(), - ) - .await - { - Ok(_) => { - logging!(info, Type::Window, "DNS设置已恢复"); - true - } - Err(_) => { - logging!(warn, Type::Window, "Warning: 恢复DNS设置超时"); - false - } - } - #[cfg(not(target_os = "macos"))] - true - }); - - // 并行执行清理任务 - let (proxy_result, core_result, dns_result) = tokio::join!(proxy_task, core_task, dns_task); - - let proxy_success = proxy_result.unwrap_or_default(); - let core_success = core_result.unwrap_or_default(); - let dns_success = dns_result.unwrap_or_default(); - - let all_success = proxy_success && core_success && dns_success; + }, + restore_dns_after_core_stop, + ) + .await; logging!( info, Type::System, - "异步关闭操作完成 - 代理: {}, 核心: {}, DNS: {}, 总体: {}", - proxy_success, - core_success, - dns_success, - all_success + "Interactive cleanup complete - core stopped: {}, all cleanup successful: {}", + result.core_stopped, + result.all_success ); - all_success + result +} + +pub async fn clean_session_ending_best_effort() -> CleanupResult { + #[cfg(target_os = "windows")] + let stop_timeout = Duration::from_secs(2); + #[cfg(not(target_os = "windows"))] + let stop_timeout = Duration::from_secs(3); + + logging!( + info, + Type::System, + "Starting bounded session-ending best-effort cleanup" + ); + + let result = run_session_ending_cleanup_transition( + || async { + logging!(info, Type::System, "Stopping core during session-ending best-effort cleanup"); + match CoreManager::global().stop_core().await { + Ok(()) => { + logging!(info, Type::Window, "Core stopped during session-ending best-effort cleanup"); + true + } + Err(error) => { + logging!( + warn, + Type::Window, + "Session-ending best-effort core stop failed; OS or session exit is already in progress: {error:#}" + ); + false + } + } + }, + async move { + tokio::time::sleep(stop_timeout).await; + logging!( + warn, + Type::Window, + "Session-ending best-effort core stop timed out after {} seconds; OS or session exit is already in progress", + stop_timeout.as_secs() + ); + }, + restore_dns_after_core_stop, + ) + .await; + + logging!( + info, + Type::System, + "Session-ending best-effort cleanup finished - core stopped: {}, all cleanup successful: {}", + result.core_stopped, + result.all_success + ); + + result } #[cfg(target_os = "macos")] @@ -183,3 +256,168 @@ pub async fn hide() { } handle::Handle::global().set_activation_policy_accessory(); } + +#[cfg(test)] +mod tests { + use super::{ + run_interactive_cleanup_transition, run_session_ending_cleanup_transition, should_abort_exit_after_cleanup, + }; + use parking_lot::Mutex; + use std::{ + future::pending, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + task::Poll, + }; + use tokio::sync::Barrier; + + struct CancellationProbe { + cancelled: Arc, + completed: Arc, + } + + impl Drop for CancellationProbe { + fn drop(&mut self) { + if !self.completed.load(Ordering::Acquire) { + self.cancelled.store(true, Ordering::Release); + } + } + } + + #[test] + fn exit_aborts_when_controlled_core_stop_fails() { + assert!(should_abort_exit_after_cleanup(false)); + assert!(!should_abort_exit_after_cleanup(true)); + } + + #[tokio::test] + async fn interactive_cleanup_awaits_barrier_controlled_stop_without_cancellation() { + let calls = Arc::new(Mutex::new(Vec::new())); + let stop_started = Arc::new(Barrier::new(2)); + let release_stop = Arc::new(Barrier::new(2)); + let stop_cancelled = Arc::new(AtomicBool::new(false)); + let stop_completed = Arc::new(AtomicBool::new(false)); + + let mut cleanup = Box::pin(run_interactive_cleanup_transition( + { + let calls = Arc::clone(&calls); + let stop_started = Arc::clone(&stop_started); + let release_stop = Arc::clone(&release_stop); + let stop_cancelled = Arc::clone(&stop_cancelled); + let stop_completed = Arc::clone(&stop_completed); + move || async move { + let _probe = CancellationProbe { + cancelled: stop_cancelled, + completed: Arc::clone(&stop_completed), + }; + calls.lock().push("core_stop"); + stop_started.wait().await; + release_stop.wait().await; + stop_completed.store(true, Ordering::Release); + true + } + }, + { + let calls = Arc::clone(&calls); + move || async move { + calls.lock().push("ancillary_cleanup"); + true + } + }, + )); + + assert!(matches!(futures::poll!(cleanup.as_mut()), Poll::Pending)); + stop_started.wait().await; + assert!(matches!(futures::poll!(cleanup.as_mut()), Poll::Pending)); + assert!(!stop_cancelled.load(Ordering::Acquire)); + assert_eq!(&*calls.lock(), &["core_stop"]); + + release_stop.wait().await; + let result = cleanup.await; + + assert!(result.core_stopped); + assert!(result.all_success); + assert!(!stop_cancelled.load(Ordering::Acquire)); + assert_eq!(&*calls.lock(), &["core_stop", "ancillary_cleanup"]); + } + + #[tokio::test] + async fn interactive_cleanup_does_not_run_ancillary_cleanup_after_stop_failure() { + let calls = Mutex::new(Vec::new()); + + let result = run_interactive_cleanup_transition( + || async { + calls.lock().push("core_stop"); + false + }, + || async { + calls.lock().push("ancillary_cleanup"); + true + }, + ) + .await; + + assert!(!result.core_stopped); + assert!(!result.all_success); + assert_eq!(&*calls.lock(), &["core_stop"]); + } + + #[tokio::test] + async fn session_ending_cleanup_may_cancel_stop_and_skips_ancillary_after_timeout() { + let calls = Arc::new(Mutex::new(Vec::new())); + let stop_started = Arc::new(Barrier::new(2)); + let deadline_started = Arc::new(Barrier::new(2)); + let release_deadline = Arc::new(Barrier::new(2)); + let stop_cancelled = Arc::new(AtomicBool::new(false)); + let stop_completed = Arc::new(AtomicBool::new(false)); + + let mut cleanup = Box::pin(run_session_ending_cleanup_transition( + { + let calls = Arc::clone(&calls); + let stop_started = Arc::clone(&stop_started); + let stop_cancelled = Arc::clone(&stop_cancelled); + let stop_completed = Arc::clone(&stop_completed); + move || async move { + let _probe = CancellationProbe { + cancelled: stop_cancelled, + completed: stop_completed, + }; + calls.lock().push("core_stop"); + stop_started.wait().await; + pending::().await + } + }, + { + let deadline_started = Arc::clone(&deadline_started); + let release_deadline = Arc::clone(&release_deadline); + async move { + deadline_started.wait().await; + release_deadline.wait().await; + } + }, + { + let calls = Arc::clone(&calls); + move || async move { + calls.lock().push("ancillary_cleanup"); + true + } + }, + )); + + assert!(matches!(futures::poll!(cleanup.as_mut()), Poll::Pending)); + stop_started.wait().await; + deadline_started.wait().await; + assert!(matches!(futures::poll!(cleanup.as_mut()), Poll::Pending)); + assert!(!stop_cancelled.load(Ordering::Acquire)); + + release_deadline.wait().await; + let result = cleanup.await; + + assert!(!result.core_stopped); + assert!(!result.all_success); + assert!(stop_cancelled.load(Ordering::Acquire)); + assert_eq!(&*calls.lock(), &["core_stop"]); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6b050c095..690a1b427 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -137,6 +137,7 @@ mod app_init { cmd::is_port_in_use, cmd::get_sys_proxy, cmd::get_auto_proxy, + cmd::get_embedded_server_port, cmd::open_app_dir, cmd::open_logs_dir, cmd::open_web_url, @@ -157,6 +158,8 @@ mod app_init { cmd::reinstall_service, cmd::repair_service, cmd::is_service_available, + cmd::get_service_install_state, + cmd::continue_with_sidecar, cmd::get_clash_info, cmd::patch_clash_config, cmd::patch_clash_mode, @@ -220,6 +223,14 @@ mod app_init { } pub fn run() { + #[cfg(all(target_os = "macos", not(debug_assertions), not(test), not(feature = "verge-dev")))] + if utils::macos_launch_guard::enforce_before_initialization() == utils::macos_launch_guard::LaunchDisposition::Exit + { + return; + } + + let _ = utils::dirs::init_portable_flag(); + if app_init::init_singleton_check().is_err() { return; } @@ -229,8 +240,6 @@ pub fn run() { #[cfg(target_os = "linux")] utils::linux::workarounds::apply_wayland_webkit_fix(); - let _ = utils::dirs::init_portable_flag(); - let builder = app_init::setup_plugins(tauri::Builder::default()) .setup(|app| { // Logger may not be ready yet, so mirror setup panics to stderr. @@ -317,6 +326,7 @@ pub fn run() { } logging!(info, Type::System, "应用就绪"); + crate::utils::server::set_commands_ready(); #[cfg(target_os = "macos")] if let Some(window) = _app_handle.get_webview_window("main") { @@ -437,7 +447,15 @@ pub fn run() { // Windows session ending currently reaches Tao as WM_ENDSESSION and // destroys the loop without a preventable ExitRequested event. if !handle::Handle::global().is_exiting() { - feat::quit().await; + handle::Handle::global().set_is_exiting(); + let cleanup_result = feat::clean_session_ending_best_effort().await; + logging!( + info, + Type::System, + "Unpreventable session-ending best-effort cleanup returned - core stopped: {}, all cleanup successful: {}", + cleanup_result.core_stopped, + cleanup_result.all_success + ); } logging!(info, Type::System, "Application exited"); }), diff --git a/src-tauri/src/utils/dirs.rs b/src-tauri/src/utils/dirs.rs index 453f6365a..5db805a93 100644 --- a/src-tauri/src/utils/dirs.rs +++ b/src-tauri/src/utils/dirs.rs @@ -3,8 +3,6 @@ use anyhow::Result; use async_trait::async_trait; use clash_verge_logging::{Type, logging}; use once_cell::sync::OnceCell; -#[cfg(unix)] -use std::iter; use std::{fs, path::PathBuf}; use tauri::Manager as _; @@ -66,6 +64,28 @@ pub fn app_home_dir() -> Result { } } +pub fn preinit_app_data_dir() -> Result { + if PORTABLE_FLAG.get().copied().unwrap_or(false) { + let executable = std::env::current_exe()?; + let parent = executable + .parent() + .ok_or_else(|| anyhow::anyhow!("portable executable has no parent directory"))?; + return Ok(parent.join(".config").join(APP_ID)); + } + + #[cfg(target_os = "macos")] + let root = PathBuf::from(std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME is unavailable"))?) + .join("Library/Application Support"); + #[cfg(target_os = "linux")] + let root = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share")); + #[cfg(windows)] + let root = PathBuf::from(std::env::var_os("APPDATA").ok_or_else(|| anyhow::anyhow!("APPDATA is unavailable"))?); + + Ok(root.join(APP_ID)) +} + /// get the resources dir pub fn app_resources_dir() -> Result { // 避免在Handle未初始化时崩溃 @@ -213,40 +233,155 @@ pub fn get_encryption_key() -> Result> { } } -#[cfg(unix)] -pub fn ensure_mihomo_safe_dir() -> Option { - iter::once("/tmp") - .map(PathBuf::from) - .find(|path| path.exists()) - .or_else(|| { - std::env::var_os("HOME").and_then(|home| { - let home_config = PathBuf::from(home).join(".config"); - if home_config.exists() || fs::create_dir_all(&home_config).is_ok() { - Some(home_config) - } else { - logging!(error, Type::File, "Failed to create safe directory: {home_config:?}"); - None - } - }) - }) +pub fn ipc_path() -> Result { + Ok(PathBuf::from(clash_verge_service_ipc::mihomo_ipc_path( + &crate::core::owner_identity::current_owner_identity()?, + ))) } -#[cfg(unix)] -pub fn ipc_path() -> Result { - ensure_mihomo_safe_dir() - .map(|base_dir| base_dir.join("verge").join("verge-mihomo.sock")) - .or_else(|| { - app_home_dir() - .ok() - .map(|dir| dir.join("verge").join("verge-mihomo.sock")) - }) - .ok_or_else(|| anyhow::anyhow!("Failed to determine ipc path")) +#[cfg(target_os = "macos")] +pub fn sidecar_ipc_path() -> Result { + sidecar_ipc_path_for( + std::path::Path::new(""), + &crate::core::owner_identity::current_owner_identity()?, + ) } -#[cfg(target_os = "windows")] -pub fn ipc_path() -> Result { - Ok(PathBuf::from(r"\\.\pipe\verge-mihomo")) +#[cfg(not(target_os = "macos"))] +pub fn sidecar_ipc_path() -> Result { + Ok(sidecar_ipc_path_for( + &preinit_app_data_dir()?, + &crate::core::owner_identity::current_owner_identity()?, + )) } + +#[cfg(target_os = "linux")] +fn sidecar_ipc_path_for(app_root: &std::path::Path, _identity: &clash_verge_service_ipc::OwnerIdentity) -> PathBuf { + app_root.join("verge-mihomo.sock") +} + +#[cfg(target_os = "macos")] +fn sidecar_ipc_path_for( + _app_root: &std::path::Path, + _identity: &clash_verge_service_ipc::OwnerIdentity, +) -> Result { + use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _}; + + // SAFETY: A null buffer with size zero asks confstr for the required buffer length. + let required_len = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, std::ptr::null_mut(), 0) }; + if required_len == 0 { + return Err(anyhow::anyhow!("macOS per-user temporary directory is unavailable")); + } + + let mut buffer = vec![0_u8; required_len]; + // SAFETY: buffer is writable for buffer.len() bytes, as required by confstr. + let written = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, buffer.as_mut_ptr().cast(), buffer.len()) }; + if written == 0 || written > buffer.len() { + return Err(anyhow::anyhow!("failed to read macOS per-user temporary directory")); + } + + let root = std::ffi::CStr::from_bytes_until_nul(&buffer) + .map_err(|_| anyhow::anyhow!("macOS per-user temporary directory is not NUL-terminated"))?; + #[cfg(feature = "verge-dev")] + let filename = "verge-mihomo-dev.sock"; + #[cfg(not(feature = "verge-dev"))] + let filename = "verge-mihomo.sock"; + let path = PathBuf::from(OsStr::from_bytes(root.to_bytes())).join(filename); + + let path_len = path.as_os_str().as_bytes().len(); + if path_len >= 104 { + return Err(anyhow::anyhow!( + "macOS Sidecar IPC path is {path_len} bytes, but sockaddr_un.sun_path requires fewer than 104 bytes: {:?}", + path, + )); + } + + Ok(path) +} + +#[cfg(windows)] +fn sidecar_ipc_path_for(_app_root: &std::path::Path, identity: &clash_verge_service_ipc::OwnerIdentity) -> PathBuf { + PathBuf::from(sidecar_pipe_name(identity, cfg!(feature = "verge-dev"))) +} + +#[cfg(any(windows, test))] +fn sidecar_pipe_name(identity: &clash_verge_service_ipc::OwnerIdentity, is_dev: bool) -> String { + let flavor = if is_dev { "dev" } else { "release" }; + format!( + r"\\.\pipe\verge-mihomo-sidecar-{flavor}-{}", + clash_verge_service_ipc::owner_key(identity) + ) +} + +#[cfg(all(test, target_os = "linux"))] +mod ipc_tests { + use super::sidecar_ipc_path_for; + use clash_verge_service_ipc::OwnerIdentity; + use std::path::Path; + + #[test] + fn sidecar_ipc_stays_in_the_app_root() { + let identity = OwnerIdentity::Unix { uid: 501, gid: 20 }; + let app_root = Path::new("/home/test/.local/share/io.github.clash-verge-rev.clash-verge-rev"); + let path = sidecar_ipc_path_for(app_root, &identity); + + assert_eq!(path, app_root.join("verge-mihomo.sock")); + assert_ne!( + path.to_string_lossy(), + clash_verge_service_ipc::mihomo_ipc_path(&identity) + ); + } +} + +#[cfg(all(test, target_os = "macos"))] +mod ipc_tests { + use super::sidecar_ipc_path_for; + use clash_verge_service_ipc::OwnerIdentity; + use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _, path::Path}; + + #[test] + fn sidecar_ipc_ignores_long_app_root_and_fits_sockaddr_un() -> anyhow::Result<()> { + let identity = OwnerIdentity::Unix { uid: 501, gid: 20 }; + let app_root = + Path::new("/Users/support/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev"); + let path = sidecar_ipc_path_for(app_root, &identity)?; + + assert!(!path.starts_with(app_root)); + assert!(path.as_os_str().as_bytes().len() < 104); + #[cfg(feature = "verge-dev")] + assert_eq!(path.file_name(), Some(OsStr::new("verge-mihomo-dev.sock"))); + #[cfg(not(feature = "verge-dev"))] + assert_eq!(path.file_name(), Some(OsStr::new("verge-mihomo.sock"))); + assert_eq!(path, sidecar_ipc_path_for(Path::new("/different/root"), &identity)?); + assert!(path.parent().is_some_and(Path::is_dir)); + Ok(()) + } +} + +#[cfg(all(test, windows))] +mod ipc_tests { + use super::sidecar_ipc_path_for; + use clash_verge_service_ipc::OwnerIdentity; + use std::path::Path; + + #[test] + fn sidecar_ipc_uses_the_current_owners_named_pipe() { + let identity = OwnerIdentity::Windows { + sid: "S-1-5-21-1000".to_owned(), + }; + let path = sidecar_ipc_path_for(Path::new(r"C:\ignored"), &identity); + + assert_eq!( + path, + Path::new(&format!( + r"\\.\pipe\verge-mihomo-sidecar-{}-{}", + if cfg!(feature = "verge-dev") { "dev" } else { "release" }, + clash_verge_service_ipc::owner_key(&identity) + )) + ); + } +} + #[async_trait] pub trait PathBufExec { async fn remove_if_exists(&self) -> Result<()>; @@ -262,3 +397,26 @@ impl PathBufExec for PathBuf { Ok(()) } } + +#[cfg(test)] +mod windows_pipe_name_tests { + use super::sidecar_pipe_name; + use clash_verge_service_ipc::OwnerIdentity; + + #[test] + fn windows_sidecar_pipe_separates_dev_and_release_for_the_same_owner() { + let identity = OwnerIdentity::Windows { + sid: "S-1-5-21-1000".to_owned(), + }; + let owner_key = clash_verge_service_ipc::owner_key(&identity); + + assert_eq!( + sidecar_pipe_name(&identity, false), + format!(r"\\.\pipe\verge-mihomo-sidecar-release-{owner_key}") + ); + assert_eq!( + sidecar_pipe_name(&identity, true), + format!(r"\\.\pipe\verge-mihomo-sidecar-dev-{owner_key}") + ); + } +} diff --git a/src-tauri/src/utils/help.rs b/src-tauri/src/utils/help.rs index 316602e3c..90e2eb17f 100644 --- a/src-tauri/src/utils/help.rs +++ b/src-tauri/src/utils/help.rs @@ -213,8 +213,18 @@ pub fn open_app_latest_log() -> Result<()> { open_latest_log(path) } -pub fn open_core_latest_log() -> Result<()> { - let path = crate::utils::dirs::clash_latest_log()?; +pub async fn open_core_latest_log() -> Result<()> { + let path = if matches!( + *crate::core::CoreManager::global().get_running_mode(), + crate::core::manager::RunningMode::Service + ) { + let path = crate::utils::dirs::service_log_dir()?.join("service_latest.log"); + let snapshot = crate::core::service::get_clash_log_snapshot_by_service().await?; + tokio::fs::write(&path, snapshot).await?; + path + } else { + crate::utils::dirs::clash_latest_log()? + }; open_latest_log(path) } diff --git a/src-tauri/src/utils/macos_launch_guard.rs b/src-tauri/src/utils/macos_launch_guard.rs new file mode 100644 index 000000000..1fa5869df --- /dev/null +++ b/src-tauri/src/utils/macos_launch_guard.rs @@ -0,0 +1,528 @@ +use std::{ + ffi::CStr, + os::unix::ffi::OsStrExt as _, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LaunchLocation { + Allowed { bundle: PathBuf }, + Movable { bundle: PathBuf }, + Translocated, + Rejected { reason: LaunchRejectionReason }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LaunchRejectionReason { + NotInApplicationBundle, + CanonicalizeBundleFailed { error: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MoveDecision { + SystemApplications, + UserApplications, + ConfirmReplacement, + ManualInstructions, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaunchDisposition { + Continue, + Exit, +} + +pub fn evaluate_install_location(executable: &Path, home: &Path) -> LaunchLocation { + evaluate_install_location_with_roots(executable, home, Path::new("/Applications")) +} + +pub fn evaluate_install_location_with_roots( + executable: &Path, + home: &Path, + system_applications: &Path, +) -> LaunchLocation { + if executable + .components() + .any(|component| component.as_os_str() == "AppTranslocation") + { + return LaunchLocation::Translocated; + } + + let Some(bundle) = executable.ancestors().find(|ancestor| { + ancestor + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("app")) + }) else { + return LaunchLocation::Rejected { + reason: LaunchRejectionReason::NotInApplicationBundle, + }; + }; + let canonical_bundle = match std::fs::canonicalize(bundle) { + Ok(bundle) => bundle, + Err(error) => { + return LaunchLocation::Rejected { + reason: LaunchRejectionReason::CanonicalizeBundleFailed { + error: error.to_string(), + }, + }; + } + }; + let system_root = canonical_allowed_root(system_applications); + let user_root = canonical_allowed_root(&home.join("Applications")); + let allowed = system_root + .as_ref() + .is_some_and(|root| canonical_bundle.starts_with(root)) + || user_root + .as_ref() + .is_some_and(|root| canonical_bundle.starts_with(root)); + + if allowed { + LaunchLocation::Allowed { + bundle: canonical_bundle, + } + } else { + LaunchLocation::Movable { + bundle: canonical_bundle, + } + } +} + +fn canonical_allowed_root(path: &Path) -> Option { + let metadata = std::fs::symlink_metadata(path).ok()?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return None; + } + std::fs::canonicalize(path).ok() +} + +fn current_user_home() -> anyhow::Result { + const DEFAULT_BUFFER_SIZE: usize = 16 * 1024; + const MAX_BUFFER_SIZE: usize = 1024 * 1024; + + let uid = unsafe { libc::geteuid() }; + let configured_size = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) }; + let mut buffer_size = if configured_size > 0 { + usize::try_from(configured_size).unwrap_or(DEFAULT_BUFFER_SIZE) + } else { + DEFAULT_BUFFER_SIZE + } + .max(1024); + + loop { + if buffer_size > MAX_BUFFER_SIZE { + anyhow::bail!("effective-user home lookup exceeded the maximum buffer size"); + } + + let mut passwd = std::mem::MaybeUninit::::zeroed(); + let mut result = std::ptr::null_mut(); + let mut buffer = vec![0_u8; buffer_size]; + let code = unsafe { + libc::getpwuid_r( + uid, + passwd.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + if code == libc::ERANGE { + buffer_size = buffer_size.saturating_mul(2); + continue; + } + if code != 0 { + return Err(std::io::Error::from_raw_os_error(code).into()); + } + if result.is_null() { + anyhow::bail!("effective user {uid} has no password database entry"); + } + + let passwd = unsafe { passwd.assume_init() }; + if passwd.pw_dir.is_null() { + anyhow::bail!("effective user {uid} has no home directory"); + } + let home = unsafe { CStr::from_ptr(passwd.pw_dir) }; + if home.to_bytes().is_empty() { + anyhow::bail!("effective user {uid} has an empty home directory"); + } + return Ok(PathBuf::from(std::ffi::OsStr::from_bytes(home.to_bytes()))); + } +} + +pub const fn move_decision( + translocated: bool, + system_applications_writable: bool, + target_exists: bool, +) -> MoveDecision { + if translocated { + MoveDecision::ManualInstructions + } else if target_exists { + MoveDecision::ConfirmReplacement + } else if system_applications_writable { + MoveDecision::SystemApplications + } else { + MoveDecision::UserApplications + } +} + +pub fn enforce_before_initialization() -> LaunchDisposition { + clash_verge_i18n::sync_locale(None); + + let executable = match std::env::current_exe() { + Ok(executable) => executable, + Err(error) => { + let message = clash_verge_i18n::t!("launchGuard.currentExeError").replace("{error}", &error.to_string()); + show_message(&message); + return LaunchDisposition::Exit; + } + }; + let home = match current_user_home() { + Ok(home) => home, + Err(error) => { + let message = clash_verge_i18n::t!("launchGuard.homeDirError").replace("{error}", &error.to_string()); + show_message(&message); + return LaunchDisposition::Exit; + } + }; + + match evaluate_install_location(&executable, &home) { + LaunchLocation::Allowed { .. } => LaunchDisposition::Continue, + LaunchLocation::Translocated => { + show_message(&clash_verge_i18n::t!("launchGuard.translocated")); + LaunchDisposition::Exit + } + LaunchLocation::Rejected { reason } => { + let reason = match reason { + LaunchRejectionReason::NotInApplicationBundle => { + clash_verge_i18n::t!("launchGuard.notInBundle").into_owned() + } + LaunchRejectionReason::CanonicalizeBundleFailed { error } => { + clash_verge_i18n::t!("launchGuard.canonicalizeBundleError").replace("{error}", &error) + } + }; + let message = clash_verge_i18n::t!("launchGuard.rejected").replace("{reason}", &reason); + show_message(&message); + LaunchDisposition::Exit + } + LaunchLocation::Movable { bundle } => move_and_relaunch(&bundle, &home), + } +} + +fn move_and_relaunch(bundle: &Path, home: &Path) -> LaunchDisposition { + let system_root = Path::new("/Applications"); + let system_writable = path_is_writable(system_root); + let destination_root = if system_writable { + system_root.to_path_buf() + } else { + home.join("Applications") + }; + let Some(bundle_name) = bundle.file_name() else { + show_message(&clash_verge_i18n::t!("launchGuard.bundleNameError")); + return LaunchDisposition::Exit; + }; + let destination = destination_root.join(bundle_name); + let exists = destination.exists(); + let destination_display = destination.display().to_string(); + let prompt = if exists { + clash_verge_i18n::t!("launchGuard.replacePrompt").replace("{destination}", &destination_display) + } else { + clash_verge_i18n::t!("launchGuard.movePrompt").replace("{destination}", &destination_display) + }; + if !confirm(&prompt) { + return LaunchDisposition::Exit; + } + if let Err(error) = std::fs::create_dir_all(&destination_root) { + let message = + clash_verge_i18n::t!("launchGuard.createApplicationsDirError").replace("{error}", &error.to_string()); + show_message(&message); + return LaunchDisposition::Exit; + } + let staging = sibling_swap_path(&destination, "installing"); + let backup = sibling_swap_path(&destination, "backup"); + let _ = remove_existing_target(&staging); + let _ = remove_existing_target(&backup); + let copy = std::process::Command::new("/usr/bin/ditto") + .arg(bundle) + .arg(&staging) + .status(); + if !copy.is_ok_and(|status| status.success()) { + let _ = remove_existing_target(&staging); + show_message(&clash_verge_i18n::t!("launchGuard.moveFailed")); + return LaunchDisposition::Exit; + } + let backup = match activate_staged_bundle(&staging, &destination, &backup) { + Ok(backup) => backup, + Err(error) => { + let _ = remove_existing_target(&staging); + let message = clash_verge_i18n::t!("launchGuard.replaceFailed").replace("{error}", &error.to_string()); + show_message(&message); + return LaunchDisposition::Exit; + } + }; + let relaunched = std::process::Command::new("/usr/bin/open") + .args(["-n"]) + .arg(&destination) + .status() + .is_ok_and(|status| status.success()); + if !relaunched { + if let Some(backup) = backup.as_ref() { + let _ = remove_existing_target(&staging); + if std::fs::rename(&destination, &staging).is_ok() { + let _ = std::fs::rename(backup, &destination); + let _ = remove_existing_target(&staging); + } + } + show_message(&clash_verge_i18n::t!("launchGuard.relaunchFailed")); + return LaunchDisposition::Exit; + } + if let Some(backup) = backup { + let _ = remove_existing_target(&backup); + } + if bundle != destination { + let _ = std::fs::remove_dir_all(bundle); + } + LaunchDisposition::Exit +} + +fn sibling_swap_path(destination: &Path, label: &str) -> PathBuf { + let name = destination + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Clash Verge.app"); + destination.with_file_name(format!(".{name}.{label}-{}", std::process::id())) +} + +fn activate_staged_bundle(staging: &Path, destination: &Path, backup: &Path) -> std::io::Result> { + let staging_metadata = std::fs::symlink_metadata(staging)?; + if staging_metadata.file_type().is_symlink() || !staging_metadata.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "staged application is not an ordinary bundle directory", + )); + } + + let had_destination = std::fs::symlink_metadata(destination).is_ok(); + if had_destination { + std::fs::rename(destination, backup)?; + } + if let Err(error) = std::fs::rename(staging, destination) { + if had_destination { + let _ = std::fs::rename(backup, destination); + } + return Err(error); + } + Ok(had_destination.then(|| backup.to_path_buf())) +} + +fn remove_existing_target(path: &Path) -> std::io::Result<()> { + let metadata = std::fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || metadata.is_file() { + std::fs::remove_file(path) + } else { + std::fs::remove_dir_all(path) + } +} + +fn path_is_writable(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt as _; + + let Ok(path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + unsafe { libc::access(path.as_ptr(), libc::W_OK) == 0 } +} + +fn confirm(message: &str) -> bool { + let cancel_button = clash_verge_i18n::t!("launchGuard.cancel"); + let move_button = clash_verge_i18n::t!("launchGuard.move"); + let script = format!( + "display dialog \"{}\" buttons {{\"{}\", \"{}\"}} default button \"{}\" with icon caution", + escape_osascript(message), + escape_osascript(cancel_button.as_ref()), + escape_osascript(move_button.as_ref()), + escape_osascript(move_button.as_ref()) + ); + let selected_button = format!("button returned:{move_button}"); + std::process::Command::new("/usr/bin/osascript") + .args(["-e", &script]) + .output() + .is_ok_and(|output| { + output.status.success() && String::from_utf8_lossy(&output.stdout).contains(&selected_button) + }) +} + +fn show_message(message: &str) { + let ok_button = clash_verge_i18n::t!("launchGuard.ok"); + let script = format!( + "display dialog \"{}\" buttons {{\"{}\"}} default button \"{}\" with icon caution", + escape_osascript(message), + escape_osascript(ok_button.as_ref()), + escape_osascript(ok_button.as_ref()) + ); + let _ = std::process::Command::new("/usr/bin/osascript") + .args(["-e", &script]) + .status(); +} + +fn escape_osascript(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n") +} + +#[cfg(test)] +mod tests { + use super::{ + LaunchLocation, MoveDecision, activate_staged_bundle, current_user_home, evaluate_install_location_with_roots, + move_decision, + }; + use std::{ffi::CStr, os::unix::ffi::OsStrExt as _}; + + fn executable(bundle: &std::path::Path) -> anyhow::Result { + let executable = bundle.join("Contents/MacOS/clash-verge"); + let parent = executable + .parent() + .ok_or_else(|| anyhow::anyhow!("test executable has no parent"))?; + std::fs::create_dir_all(parent)?; + std::fs::write(&executable, b"app")?; + Ok(executable) + } + + #[test] + fn canonical_allowed_roots_and_nested_bundles_are_accepted() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("launch-guard-{}", std::process::id())); + let home = root.join("home"); + let system = root.join("Applications"); + let user = home.join("Applications"); + let system_exe = executable(&system.join("Tools/Clash Verge.app"))?; + let user_exe = executable(&user.join("Network/Clash Verge.app"))?; + + assert!(matches!( + evaluate_install_location_with_roots(&system_exe, &home, &system), + LaunchLocation::Allowed { .. } + )); + assert!(matches!( + evaluate_install_location_with_roots(&user_exe, &home, &system), + LaunchLocation::Allowed { .. } + )); + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[test] + fn failed_staged_activation_preserves_existing_application() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("launch-guard-swap-{}", std::process::id())); + let destination = root.join("Clash Verge.app"); + let staging = root.join(".Clash Verge.app.installing"); + let backup = root.join(".Clash Verge.app.backup"); + std::fs::create_dir_all(&destination)?; + std::fs::write(destination.join("old"), b"old")?; + std::fs::create_dir_all(&staging)?; + std::fs::write(staging.join("new"), b"new")?; + std::fs::write(&backup, b"blocks rename")?; + + let Err(error) = activate_staged_bundle(&staging, &destination, &backup) else { + anyhow::bail!("occupied backup path must fail without deleting destination"); + }; + + assert_ne!(error.kind(), std::io::ErrorKind::NotFound); + assert_eq!(std::fs::read(destination.join("old"))?, b"old"); + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[cfg(unix)] + #[test] + fn canonicalization_accepts_symlink_in_and_rejects_symlink_escape() -> anyhow::Result<()> { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!("launch-guard-links-{}", std::process::id())); + let home = root.join("home"); + let system = root.join("Applications"); + let downloads = home.join("Downloads"); + std::fs::create_dir_all(&downloads)?; + let allowed_bundle = system.join("Clash Verge.app"); + let allowed_exe = executable(&allowed_bundle)?; + let link_in = downloads.join("Clash Verge.app"); + symlink(&allowed_bundle, &link_in)?; + let escaped_bundle = downloads.join("Escaped.app"); + executable(&escaped_bundle)?; + std::fs::create_dir_all(&system)?; + let link_out = system.join("Escaped.app"); + symlink(&escaped_bundle, &link_out)?; + + assert!(matches!( + evaluate_install_location_with_roots(&link_in.join("Contents/MacOS/clash-verge"), &home, &system), + LaunchLocation::Allowed { .. } + )); + assert!(matches!( + evaluate_install_location_with_roots(&link_out.join("Contents/MacOS/clash-verge"), &home, &system), + LaunchLocation::Movable { .. } + )); + assert!(allowed_exe.is_file()); + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[cfg(unix)] + #[test] + fn allowed_root_symlink_escape_is_not_authorized() -> anyhow::Result<()> { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!("launch-guard-root-link-{}", std::process::id())); + let home = root.join("home"); + let downloads = home.join("Downloads"); + let system = root.join("SystemApplications"); + std::fs::create_dir_all(&downloads)?; + std::fs::create_dir_all(&system)?; + symlink(&downloads, home.join("Applications"))?; + let escaped_exe = executable(&home.join("Applications/Clash Verge.app"))?; + + assert!(matches!( + evaluate_install_location_with_roots(&escaped_exe, &home, &system), + LaunchLocation::Movable { .. } + )); + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[test] + fn current_home_matches_effective_user_database_entry() -> anyhow::Result<()> { + let home = current_user_home()?; + let passwd = unsafe { libc::getpwuid(libc::geteuid()) }; + if passwd.is_null() { + anyhow::bail!("effective user has no password database entry"); + } + let expected = unsafe { CStr::from_ptr((*passwd).pw_dir) }; + assert_eq!(home.as_os_str().as_bytes(), expected.to_bytes()); + Ok(()) + } + + #[test] + fn translocation_and_missing_bundle_are_rejected_before_side_effects() { + let translocated = + std::path::Path::new("/private/var/folders/AppTranslocation/Clash Verge.app/Contents/MacOS/clash-verge"); + assert_eq!( + evaluate_install_location_with_roots( + translocated, + std::path::Path::new("/Users/test"), + std::path::Path::new("/Applications") + ), + LaunchLocation::Translocated + ); + assert!(matches!( + evaluate_install_location_with_roots( + std::path::Path::new("/Users/test/Downloads/clash-verge"), + std::path::Path::new("/Users/test"), + std::path::Path::new("/Applications") + ), + LaunchLocation::Rejected { .. } + )); + } + + #[test] + fn mover_decision_prefers_system_then_user_and_never_moves_translocation() { + assert_eq!(move_decision(false, true, false), MoveDecision::SystemApplications); + assert_eq!(move_decision(false, false, false), MoveDecision::UserApplications); + assert_eq!(move_decision(true, true, false), MoveDecision::ManualInstructions); + assert_eq!(move_decision(false, true, true), MoveDecision::ConfirmReplacement); + } +} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index b8b509339..80f71f038 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -5,6 +5,8 @@ pub mod help; pub mod init; #[cfg(target_os = "linux")] pub mod linux; +#[cfg(target_os = "macos")] +pub mod macos_launch_guard; pub mod network; pub mod notification; pub mod resolve; diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index 7022de719..2ac3024aa 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -9,8 +9,7 @@ use crate::{ handle::Handle, hotkey::Hotkey, logger::Logger, - service::{SERVICE_MANAGER, ServiceManager, is_service_ipc_path_exists}, - sysopt, + service::{SERVICE_MANAGER, ServiceManager}, tray::Tray, }, feat, @@ -52,6 +51,7 @@ pub fn resolve_setup_async() { #[cfg(target_os = "macos")] resolve_dock_show().await; init_startup_script().await; + init_service_manager().await; let config_initialized = init_verge_config_before_window().await; init_window().await; init_resources().await; @@ -64,10 +64,7 @@ pub fn resolve_setup_async() { Config::verify_config_initialization().await; let core_init = AsyncHandler::spawn(|| async { - init_service_manager().await; init_core_manager().await; - init_system_proxy().await; - init_system_proxy_guard().await; }); let _ = futures::join!( @@ -87,7 +84,6 @@ pub fn resolve_setup_async() { } pub async fn resolve_reset_async() -> Result<(), anyhow::Error> { - sysopt::Sysopt::global().reset_sysproxy().await?; CoreManager::global().stop_core().await?; #[cfg(target_os = "macos")] @@ -152,7 +148,7 @@ async fn init_silent_updater() { // - macOS/Linux: binary is replaced, we restart the app if SilentUpdater::global().try_install_on_startup(app_handle).await { logging!(info, Type::Setup, "Update installed at startup, restarting..."); - app_handle.restart(); + feat::restart_app().await; } // No pending install — start background check/download loop @@ -190,23 +186,20 @@ pub(super) async fn init_verge_config_before_window() -> bool { pub(super) async fn init_service_manager() { clash_verge_service_ipc::set_config(Some(ServiceManager::config())).await; - if is_service_ipc_path_exists() && SERVICE_MANAGER.init().await.is_ok() { - logging_error!(Type::Setup, SERVICE_MANAGER.refresh().await); + + SERVICE_MANAGER.detect_startup_status().await; +} + +pub(super) async fn init_core_manager() -> bool { + match CoreManager::global().init().await { + Ok(initialized) => initialized, + Err(error) => { + logging!(error, Type::Setup, "core manager initialization failed: {error:#}"); + false + } } } -pub(super) async fn init_core_manager() { - logging_error!(Type::Setup, CoreManager::global().init().await); -} - -pub(super) async fn init_system_proxy() { - logging_error!(Type::Setup, sysopt::Sysopt::global().update_sysproxy().await); -} - -pub(super) async fn init_system_proxy_guard() { - sysopt::Sysopt::global().refresh_guard().await; -} - pub(super) async fn refresh_tray_menu() { logging_error!(Type::Setup, Tray::global().update_part().await); } diff --git a/src-tauri/src/utils/server.rs b/src-tauri/src/utils/server.rs index e3da3e50a..f83846ffd 100644 --- a/src-tauri/src/utils/server.rs +++ b/src-tauri/src/utils/server.rs @@ -1,92 +1,218 @@ use super::resolve; use crate::{ - cmd::is_port_in_use, - config::{Config, DEFAULT_PAC, IVerge}, + config::{Config, DEFAULT_PAC}, module::lightweight, process::AsyncHandler, - utils::window_manager::WindowManager, + utils::{dirs, window_manager::WindowManager}, }; -use anyhow::{Result, bail}; +use anyhow::{Context as _, Result, bail}; use clash_verge_logging::{Type, logging, logging_error}; use once_cell::sync::OnceCell; use parking_lot::Mutex; use reqwest::ClientBuilder; -use smartstring::alias::String; +use serde::{Deserialize, Serialize}; +use std::io::{Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use tokio::sync::oneshot; use warp::Filter as _; -#[derive(serde::Deserialize, Debug)] +const INSTANCE_TOKEN_HEADER: &str = "x-instance-token"; +const INSTANCE_RECORD_FILE: &str = "singleton-instance.json"; +const INSTANCE_LOCK_FILE: &str = "singleton-instance.lock"; + +#[derive(Deserialize, Debug)] struct QueryParam { param: String, } -// 关闭 embedded server 的信号发送端 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct InstanceRecord { + port: u16, + token: String, +} + static SHUTDOWN_SENDER: OnceCell>>> = OnceCell::new(); +static EMBEDDED_PORT: OnceCell = OnceCell::new(); +static INSTANCE_LOCK: OnceCell = OnceCell::new(); +const PAC_INITIAL_AVAILABLE: bool = false; +static PAC_AVAILABLE: AtomicBool = AtomicBool::new(PAC_INITIAL_AVAILABLE); +static COMMANDS_READY: AtomicBool = AtomicBool::new(false); +#[cfg(feature = "verge-dev")] +static DEV_QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); -/// check whether there is already exists pub async fn check_singleton() -> Result<()> { - let port = IVerge::get_singleton_port(); - if is_port_in_use(port) { - let client = ClientBuilder::new().timeout(Duration::from_millis(500)).build()?; - // 需要确保 Send - #[allow(clippy::needless_collect)] - let argvs: Vec = std::env::args().collect(); - if argvs.len() > 1 { - #[cfg(not(target_os = "macos"))] + let record_path = instance_record_path()?; + let lock = open_instance_lock(&record_path.with_file_name(INSTANCE_LOCK_FILE))?; + if !try_lock_instance(&lock)? { + let deadline = std::time::Instant::now() + Duration::from_secs(20); + while std::time::Instant::now() < deadline { + if let Ok(record) = read_instance_record(&record_path) + && notify_existing_instance(&record).await { - let param = argvs[1].as_str(); - if param.starts_with("clash:") { - client - .get(format!("http://127.0.0.1:{port}/commands/scheme?param={param}")) - .send() - .await?; - } + bail!("app exists"); } - } else { - client - .get(format!("http://127.0.0.1:{port}/commands/visible")) - .send() - .await?; + tokio::time::sleep(Duration::from_millis(50)).await; } - logging!(error, Type::Window, "failed to setup singleton listen server"); - bail!("app exists"); + bail!("another app instance is starting"); } + + let preferred = read_instance_record(&record_path).ok().map(|record| record.port); + let listener = bind_primary_listener(preferred).await?; + let port = listener.local_addr()?.port(); + let record = InstanceRecord { + port, + token: random_token()?, + }; + write_instance_record(&record_path, &record)?; + INSTANCE_LOCK + .set(lock) + .map_err(|_| anyhow::anyhow!("singleton lock is already initialized"))?; + let _ = EMBEDDED_PORT.set(port); + start_embedded_server(listener, record.token); Ok(()) } -/// The embed server only be used to implement singleton process -/// maybe it can be used as pac server later -pub fn embed_server() { +async fn bind_primary_listener(preferred: Option) -> Result { + let preferred_port = preferred.unwrap_or(0); + match tokio::net::TcpListener::bind(std::net::SocketAddrV4::new( + std::net::Ipv4Addr::LOCALHOST, + preferred_port, + )) + .await + { + Ok(listener) => Ok(listener), + Err(_) if preferred.is_some() => { + Ok(tokio::net::TcpListener::bind(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 0)).await?) + } + Err(error) => Err(error.into()), + } +} + +async fn notify_existing_instance(record: &InstanceRecord) -> bool { + let client = match ClientBuilder::new().timeout(Duration::from_millis(500)).build() { + Ok(client) => client, + Err(_) => return false, + }; + #[cfg(not(target_os = "macos"))] + let request = if let Some(arg) = std::env::args().nth(1).as_deref() { + if arg.starts_with("clash:") { + client + .get(format!("http://127.0.0.1:{}/commands/scheme", record.port)) + .query(&[("param", arg)]) + } else { + client.get(format!("http://127.0.0.1:{}/commands/visible", record.port)) + } + } else { + client.get(format!("http://127.0.0.1:{}/commands/visible", record.port)) + }; + #[cfg(target_os = "macos")] + let request = client.get(format!("http://127.0.0.1:{}/commands/visible", record.port)); + + request + .header(INSTANCE_TOKEN_HEADER, &record.token) + .send() + .await + .is_ok_and(|response| response.status().is_success()) +} + +fn instance_auth(token: String) -> impl warp::Filter + Clone { + warp::header::optional::(INSTANCE_TOKEN_HEADER) + .and_then(move |provided: Option| { + let token = token.clone(); + async move { + if provided.as_deref() == Some(token.as_str()) { + Ok::<(), warp::Rejection>(()) + } else { + Err(warp::reject()) + } + } + }) + .untuple_one() +} + +#[cfg(feature = "verge-dev")] +fn dev_quit_route( + token: String, + commands_ready: &'static AtomicBool, + quit_requested: &'static AtomicBool, + trigger: F, +) -> impl warp::Filter + Clone +where + F: Fn() + Clone + Send + Sync + 'static, +{ + instance_auth(token) + .and(warp::path!("commands" / "dev" / "quit")) + .and(warp::post()) + .and_then(move || { + let trigger = trigger.clone(); + async move { + if !commands_ready.load(Ordering::Acquire) { + return Ok::<_, warp::Rejection>(warp::reply::with_status( + String::from("starting"), + warp::http::StatusCode::SERVICE_UNAVAILABLE, + )); + } + if quit_requested + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + trigger(); + } + Ok(warp::reply::with_status( + String::from("accepted"), + warp::http::StatusCode::ACCEPTED, + )) + } + }) +} + +#[cfg(feature = "verge-dev")] +fn release_dev_quit_latch(quit_requested: &AtomicBool, outcome: clash_verge_signal::ShutdownOutcome) { + if matches!(outcome, clash_verge_signal::ShutdownOutcome::Canceled) { + quit_requested.store(false, Ordering::Release); + } +} + +fn start_embedded_server(listener: tokio::net::TcpListener, token: String) { let (shutdown_tx, shutdown_rx) = oneshot::channel(); - #[allow(clippy::expect_used)] - SHUTDOWN_SENDER - .set(Mutex::new(Some(shutdown_tx))) - .expect("failed to set shutdown signal for embedded server"); - let port = IVerge::get_singleton_port(); + let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx))); + + let auth = instance_auth(token.clone()); let visible = warp::path!("commands" / "visible").and_then(|| async { + if !COMMANDS_READY.load(Ordering::Acquire) { + return Ok::<_, warp::Rejection>(warp::reply::with_status( + "starting".to_string(), + warp::http::StatusCode::SERVICE_UNAVAILABLE, + )); + } logging!(info, Type::Window, "检测到从单例模式恢复应用窗口"); - if !lightweight::exit_lightweight_mode().await { - WindowManager::show_main_window().await; - } else { - logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口"); - }; - Ok::<_, warp::Rejection>(warp::reply::with_status::( - "ok".to_string(), - warp::http::StatusCode::OK, - )) + if crate::APP_HANDLE.get().is_some() { + if !lightweight::exit_lightweight_mode().await { + WindowManager::show_main_window().await; + } else { + logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口"); + } + } + Ok::<_, warp::Rejection>(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK)) }); let pac = warp::path!("commands" / "pac").and_then(|| async move { + if !PAC_AVAILABLE.load(Ordering::Acquire) { + return Ok::<_, warp::Rejection>( + warp::http::Response::builder() + .status(warp::http::StatusCode::SERVICE_UNAVAILABLE) + .body("PAC endpoint is inactive".to_string()) + .unwrap_or_default(), + ); + } let verge_config = Config::verge().await; let clash_config = Config::clash().await; - let verge_data = verge_config.data_arc(); let clash_data = clash_config.data_arc(); - let pac_content = verge_data.pac_file_content.as_deref().unwrap_or(DEFAULT_PAC); - let pac_port = verge_data .verge_mixed_port .unwrap_or_else(|| clash_data.get_mixed_port()); @@ -99,25 +225,31 @@ pub fn embed_server() { ) }); - // Use map instead of and_then to avoid Send issues let scheme = warp::path!("commands" / "scheme") .and(warp::query::()) .and_then(|query: QueryParam| async move { + if !COMMANDS_READY.load(Ordering::Acquire) { + return Ok::<_, warp::Rejection>(warp::reply::with_status( + "starting".to_string(), + warp::http::StatusCode::SERVICE_UNAVAILABLE, + )); + } AsyncHandler::spawn(|| async move { logging_error!(Type::Setup, resolve::resolve_scheme(&query.param).await); }); - Ok::<_, warp::Rejection>(warp::reply::with_status::( - "ok".to_string(), - warp::http::StatusCode::OK, - )) + Ok::<_, warp::Rejection>(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK)) }); - let commands = visible.or(scheme).or(pac); - + let commands = auth.clone().and(visible).or(auth.and(scheme)).or(pac); + #[cfg(feature = "verge-dev")] + let commands = commands.or(dev_quit_route(token, &COMMANDS_READY, &DEV_QUIT_REQUESTED, || { + AsyncHandler::spawn(|| async { + release_dev_quit_latch(&DEV_QUIT_REQUESTED, crate::feat::quit().await); + }); + })); AsyncHandler::spawn(move || async move { warp::serve(commands) - .bind(([127, 0, 0, 1], port)) - .await + .incoming(listener) .graceful(async { shutdown_rx.await.ok(); }) @@ -126,6 +258,31 @@ pub fn embed_server() { }); } +pub fn set_pac_available(available: bool) { + PAC_AVAILABLE.store(available, Ordering::Release); +} + +pub fn set_commands_ready() { + COMMANDS_READY.store(true, Ordering::Release); +} + +pub fn embed_server() { + if EMBEDDED_PORT.get().is_none() { + logging!( + error, + Type::Window, + "embedded server was not reserved during singleton check" + ); + } +} + +pub fn embedded_server_port() -> Result { + EMBEDDED_PORT + .get() + .copied() + .context("embedded server port is not initialized") +} + pub fn shutdown_embedded_server() { logging!(info, Type::Window, "shutting down embedded server"); if let Some(sender) = SHUTDOWN_SENDER.get() @@ -134,3 +291,332 @@ pub fn shutdown_embedded_server() { sender.send(()).ok(); } } + +fn instance_record_path() -> Result { + Ok(dirs::preinit_app_data_dir()?.join(INSTANCE_RECORD_FILE)) +} + +fn open_instance_lock(path: &Path) -> Result { + let parent = path.parent().context("singleton lock has no parent")?; + std::fs::create_dir_all(parent)?; + #[cfg(windows)] + { + crate::core::owner_identity::open_or_create_private_current_user_file(path) + } + + #[cfg(unix)] + { + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create(true); + + use std::os::unix::fs::OpenOptionsExt as _; + use std::os::unix::fs::PermissionsExt as _; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + + let file = options.open(path)?; + let metadata = file.metadata()?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("singleton lock is not an ordinary file"); + } + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + Ok(file) + } +} + +fn try_lock_instance(file: &std::fs::File) -> Result { + #[cfg(unix)] + { + use std::os::fd::AsRawFd as _; + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result == 0 { + return Ok(true); + } + let error = std::io::Error::last_os_error(); + if error + .raw_os_error() + .is_some_and(|code| code == libc::EWOULDBLOCK || code == libc::EAGAIN) + { + return Ok(false); + } + Err(error).context("failed to lock singleton file") + } + + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle as _; + use windows_sys::Win32::Foundation::{ERROR_LOCK_VIOLATION, GetLastError}; + use windows_sys::Win32::Storage::FileSystem::LockFile; + let locked = unsafe { LockFile(file.as_raw_handle(), 0, 0, u32::MAX, u32::MAX) }; + if locked != 0 { + return Ok(true); + } + if unsafe { GetLastError() } == ERROR_LOCK_VIOLATION { + return Ok(false); + } + Err(std::io::Error::last_os_error()).context("failed to lock singleton file") + } +} + +fn random_token() -> Result { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes)?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn read_instance_record(path: &Path) -> Result { + #[cfg(windows)] + let mut file = crate::core::owner_identity::open_private_current_user_file(path)?; + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt as _; + let mut options = std::fs::OpenOptions::new(); + options.read(true).custom_flags(libc::O_NOFOLLOW); + options.open(path)? + }; + let metadata = file.metadata()?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("singleton record is not an ordinary file"); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + if metadata.uid() != unsafe { libc::geteuid() } || metadata.permissions().mode() & 0o777 != 0o600 { + bail!("singleton record permissions are invalid"); + } + } + let mut content = Vec::new(); + file.read_to_end(&mut content)?; + Ok(serde_json::from_slice(&content)?) +} + +fn write_instance_record(path: &Path, record: &InstanceRecord) -> Result<()> { + let parent = path.parent().context("singleton record has no parent")?; + std::fs::create_dir_all(parent)?; + let temporary = path.with_extension(format!("json.tmp-{}-{}", std::process::id(), &random_token()?[..16])); + #[cfg(windows)] + let mut file = crate::core::owner_identity::create_private_current_user_file(&temporary)?; + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt as _; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + options.mode(0o600); + options.open(&temporary)? + }; + file.write_all(&serde_json::to_vec(record)?)?; + file.sync_all()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + drop(file); + replace_file_atomic(&temporary, path)?; + Ok(()) +} + +#[cfg(not(windows))] +fn replace_file_atomic(source: &Path, destination: &Path) -> Result<()> { + std::fs::rename(source, destination)?; + Ok(()) +} + +#[cfg(windows)] +fn replace_file_atomic(source: &Path, destination: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt as _; + use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW}; + + let mut source: Vec = source.as_os_str().encode_wide().collect(); + source.push(0); + let mut destination: Vec = destination.as_os_str().encode_wide().collect(); + destination.push(0); + if unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + return Err(std::io::Error::last_os_error()).context("failed to replace singleton record"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{InstanceRecord, PAC_AVAILABLE, bind_primary_listener, read_instance_record, write_instance_record}; + #[cfg(unix)] + use super::{open_instance_lock, try_lock_instance}; + use std::sync::atomic::Ordering; + + #[cfg(feature = "verge-dev")] + use super::{INSTANCE_TOKEN_HEADER, dev_quit_route, release_dev_quit_latch}; + #[cfg(feature = "verge-dev")] + use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize}, + }; + #[test] + fn pac_is_fail_closed_before_core_readiness() { + assert!(!PAC_AVAILABLE.load(Ordering::Acquire)); + } + + #[cfg(feature = "verge-dev")] + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn dev_quit_route_requires_private_auth_ready_state_and_dispatches_once() -> anyhow::Result<()> { + static READY: AtomicBool = AtomicBool::new(false); + static REQUESTED: AtomicBool = AtomicBool::new(false); + READY.store(false, Ordering::Release); + REQUESTED.store(false, Ordering::Release); + let dispatches = Arc::new(AtomicUsize::new(0)); + let dispatches_for_route = Arc::clone(&dispatches); + let token = "ab".repeat(32); + let route = dev_quit_route(token.clone(), &READY, &REQUESTED, move || { + dispatches_for_route.fetch_add(1, Ordering::AcqRel); + }); + + let missing = warp::test::request() + .method("POST") + .path("/commands/dev/quit") + .reply(&route) + .await; + assert_eq!(missing.status(), warp::http::StatusCode::NOT_FOUND); + + let wrong_token = warp::test::request() + .method("POST") + .path("/commands/dev/quit") + .header(INSTANCE_TOKEN_HEADER, "cd".repeat(32)) + .reply(&route) + .await; + assert_eq!(wrong_token.status(), warp::http::StatusCode::NOT_FOUND); + + let not_ready = warp::test::request() + .method("POST") + .path("/commands/dev/quit") + .header(INSTANCE_TOKEN_HEADER, &token) + .reply(&route) + .await; + assert_eq!(not_ready.status(), warp::http::StatusCode::SERVICE_UNAVAILABLE); + assert!(!REQUESTED.load(Ordering::Acquire)); + + let wrong_method = warp::test::request() + .method("GET") + .path("/commands/dev/quit") + .header(INSTANCE_TOKEN_HEADER, &token) + .reply(&route) + .await; + assert_eq!(wrong_method.status(), warp::http::StatusCode::METHOD_NOT_ALLOWED); + assert!(!REQUESTED.load(Ordering::Acquire)); + + READY.store(true, Ordering::Release); + let barrier = Arc::new(tokio::sync::Barrier::new(8)); + let accepted = futures::future::join_all((0..8).map(|_| { + let barrier = Arc::clone(&barrier); + let route = route.clone(); + let token = token.clone(); + tokio::spawn(async move { + barrier.wait().await; + warp::test::request() + .method("POST") + .path("/commands/dev/quit") + .header(INSTANCE_TOKEN_HEADER, token) + .reply(&route) + .await + }) + })) + .await; + for accepted in accepted { + let accepted = accepted?; + assert_eq!(accepted.status(), warp::http::StatusCode::ACCEPTED); + } + assert_eq!(dispatches.load(Ordering::Acquire), 1); + + release_dev_quit_latch(&REQUESTED, clash_verge_signal::ShutdownOutcome::Canceled); + assert!(!REQUESTED.load(Ordering::Acquire)); + let retry = warp::test::request() + .method("POST") + .path("/commands/dev/quit") + .header(INSTANCE_TOKEN_HEADER, &token) + .reply(&route) + .await; + assert_eq!(retry.status(), warp::http::StatusCode::ACCEPTED); + assert_eq!(dispatches.load(Ordering::Acquire), 2); + Ok(()) + } + + #[tokio::test] + async fn retained_record_supplies_the_next_primarys_preferred_port() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("singleton-sticky-port-{}", std::process::id())); + let path = root.join("instance.json"); + let reservation = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; + let preferred_port = reservation.local_addr()?.port(); + drop(reservation); + write_instance_record( + &path, + &InstanceRecord { + port: preferred_port, + token: "stale-token-is-not-authority".to_string(), + }, + )?; + + let hint = read_instance_record(&path).ok().map(|record| record.port); + let listener = bind_primary_listener(hint).await?; + assert_eq!(listener.local_addr()?.port(), preferred_port); + drop(listener); + super::shutdown_embedded_server(); + assert!(path.is_file(), "clean shutdown must retain the private port hint"); + + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[tokio::test] + async fn occupied_preferred_port_falls_back_without_using_stale_authority() -> anyhow::Result<()> { + let occupied = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; + let preferred_port = occupied.local_addr()?.port(); + + let fallback = bind_primary_listener(Some(preferred_port)).await?; + assert_ne!(fallback.local_addr()?.port(), preferred_port); + assert!(fallback.local_addr()?.ip().is_loopback()); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn instance_record_is_private_and_round_trips() -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + + let root = std::env::temp_dir().join(format!("singleton-record-{}", std::process::id())); + let path = root.join("instance.json"); + let record = InstanceRecord { + port: 42_123, + token: "secret".to_string(), + }; + + write_instance_record(&path, &record)?; + + assert_eq!(read_instance_record(&path)?, record); + assert_eq!(std::fs::metadata(&path)?.permissions().mode() & 0o777, 0o600); + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[cfg(unix)] + #[test] + fn instance_lock_allows_only_one_primary() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("singleton-lock-{}", std::process::id())); + let path = root.join("instance.lock"); + let first = open_instance_lock(&path)?; + let second = open_instance_lock(&path)?; + + assert!(try_lock_instance(&first)?); + assert!(!try_lock_instance(&second)?); + drop(first); + assert!(try_lock_instance(&second)?); + + drop(second); + std::fs::remove_dir_all(root)?; + Ok(()) + } +} diff --git a/src/components/layout/service-migration-dialog.tsx b/src/components/layout/service-migration-dialog.tsx new file mode 100644 index 000000000..5ca4ec6a7 --- /dev/null +++ b/src/components/layout/service-migration-dialog.tsx @@ -0,0 +1,194 @@ +import { Alert } from '@mui/material' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { BaseDialog } from '@/components/base' +import { fetchSystemState } from '@/hooks/use-system-state' +import { useVisibility } from '@/hooks/use-visibility' +import { + continueWithSidecar, + getRunningMode, + getServiceInstallState, + installService, + reinstallService, + repairService, + restartCore, + type ServiceInstallState, +} from '@/services/cmds' +import { showNotice } from '@/services/notice-service' +import { setCacheDataAsync, useQuery } from '@/services/query-client' +const installStateQueryKey = ['getServiceInstallState'] as const + +export const ServiceMigrationDialog = () => { + const { t } = useTranslation() + const pageVisible = useVisibility() + const [loading, setLoading] = useState(false) + const [stateRefreshFailed, setStateRefreshFailed] = useState(false) + const [workflowIncomplete, setWorkflowIncomplete] = useState(false) + const { data: state } = useQuery({ + queryKey: installStateQueryKey, + queryFn: getServiceInstallState, + enabled: true, + retry: 1, + refetchInterval: pageVisible ? 2000 : false, + }) + const dialogState = stateRefreshFailed ? 'unavailable' : state + const open = + loading || + workflowIncomplete || + dialogState === 'installRequired' || + dialogState === 'needsReinstall' || + dialogState === 'unavailable' + const showCheckingMessage = + loading || + (dialogState !== 'installRequired' && + dialogState !== 'needsReinstall' && + dialogState !== 'unavailable') + + const refreshInstallState = async () => { + try { + const data = await getServiceInstallState() + await setCacheDataAsync(installStateQueryKey, data) + setStateRefreshFailed(false) + return data + } catch (error) { + setStateRefreshFailed(true) + await setCacheDataAsync( + installStateQueryKey, + 'unavailable', + ) + throw error + } + } + + const refreshSystemAndRunning = async () => { + const [systemState, runningMode] = await Promise.all([ + fetchSystemState(), + getRunningMode(), + ]) + await Promise.all([ + setCacheDataAsync(['getSystemState'], { ...systemState, runningMode }), + setCacheDataAsync(['getRunningMode'], runningMode), + ]) + } + + const handleServiceAction = async () => { + setLoading(true) + setWorkflowIncomplete(true) + let actionSucceeded = false + try { + if (dialogState === 'installRequired') { + await installService() + } else if (dialogState === 'unavailable') { + await repairService() + } else { + await reinstallService() + } + actionSucceeded = true + } catch (error) { + showNotice.error(error) + } + + let initialRefreshSucceeded = false + try { + await refreshInstallState() + initialRefreshSucceeded = true + } catch (error) { + showNotice.error(error) + } + if (!actionSucceeded || !initialRefreshSucceeded) { + setLoading(false) + return + } + + let restartSucceeded = false + try { + await restartCore() + restartSucceeded = true + } catch (error) { + showNotice.error(error) + } + + let finalRefreshSucceeded = false + try { + await refreshInstallState() + finalRefreshSucceeded = true + } catch (error) { + showNotice.error(error) + } + let revalidationSucceeded = false + try { + await refreshSystemAndRunning() + revalidationSucceeded = true + } catch (error) { + showNotice.error(error) + } + if (restartSucceeded && finalRefreshSucceeded && revalidationSucceeded) { + setWorkflowIncomplete(false) + showNotice.success('layout.components.serviceMigration.success') + } + setLoading(false) + } + + const handleContinue = async () => { + setLoading(true) + setWorkflowIncomplete(true) + let startupError: unknown + try { + await continueWithSidecar() + } catch (error) { + startupError = error + } + + let installRefreshSucceeded = false + try { + await refreshInstallState() + installRefreshSucceeded = true + } catch (error) { + showNotice.error(error) + } + let revalidationSucceeded = false + try { + await refreshSystemAndRunning() + revalidationSucceeded = true + } catch (error) { + showNotice.error(error) + } + if (startupError) { + showNotice.error(startupError) + } else if (installRefreshSucceeded && revalidationSucceeded) { + setWorkflowIncomplete(false) + } + setLoading(false) + } + + return ( + void handleServiceAction()} + onCancel={() => void handleContinue()} + > + + {t( + showCheckingMessage + ? 'layout.components.serviceMigration.checkingMessage' + : dialogState === 'installRequired' || dialogState === 'unavailable' + ? 'layout.components.serviceMigration.unavailableMessage' + : 'layout.components.serviceMigration.message', + )} + + + ) +} diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 7465870a2..0dd5f4e42 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -37,6 +37,7 @@ import { useVerge } from '@/hooks/use-verge' import { useClashConfigData, useSystemData } from '@/providers/app-data-context' import { getAutotemProxy, + getEmbeddedServerPort, getNetworkInterfacesInfo, getSystemHostname, getSystemProxy, @@ -134,6 +135,13 @@ export const SysproxyViewer = forwardRef((props, ref) => { pac_content: pac_file_content ?? DEFAULT_PAC, proxy_host: proxy_host ?? '127.0.0.1', }) + const [embeddedServerPort, setEmbeddedServerPort] = useState( + null, + ) + + useEffect(() => { + getEmbeddedServerPort().then(setEmbeddedServerPort).catch(console.error) + }, []) const separator = useMemo(() => (isWindows ? ';' : ','), [isWindows]) @@ -203,10 +211,10 @@ export const SysproxyViewer = forwardRef((props, ref) => { ]) const getCurrentPacUrl = useMemo(() => { const host = value.proxy_host || '127.0.0.1' - // 根据环境判断PAC端口 - const port = import.meta.env.DEV ? 11233 : 33331 - return `http://${host}:${port}/commands/pac` - }, [value.proxy_host]) + return embeddedServerPort + ? `http://${host}:${embeddedServerPort}/commands/pac` + : '-' + }, [embeddedServerPort, value.proxy_host]) const bypassError = value.enable_bypass_check && diff --git a/src/components/setting/mods/update-viewer.tsx b/src/components/setting/mods/update-viewer.tsx index 1d260681e..aa7c9c077 100644 --- a/src/components/setting/mods/update-viewer.tsx +++ b/src/components/setting/mods/update-viewer.tsx @@ -1,5 +1,4 @@ import { alpha, Box, Button, LinearProgress } from '@mui/material' -import { relaunch } from '@tauri-apps/plugin-process' import { open as openUrl } from '@tauri-apps/plugin-shell' import type { DownloadEvent } from '@tauri-apps/plugin-updater' import { useLockFn } from 'ahooks' @@ -17,6 +16,7 @@ import type { Options as ReactMarkdownOptions } from 'react-markdown' import { BaseDialog, DialogRef } from '@/components/base' import { useUpdate } from '@/hooks/use-update' +import { restartApp } from '@/services/cmds' import { showNotice } from '@/services/notice-service' import { useSetUpdateState, useUpdateState } from '@/services/states' @@ -197,7 +197,7 @@ export function UpdateViewer({ ref }: { ref?: Ref }) { try { await updateInfo.downloadAndInstall(onDownloadEvent) - await relaunch() + await restartApp() } catch (err: any) { showNotice.error(err) } finally { diff --git a/src/components/shared/proxy-control-switches.tsx b/src/components/shared/proxy-control-switches.tsx index 50c750299..b8f7c4746 100644 --- a/src/components/shared/proxy-control-switches.tsx +++ b/src/components/shared/proxy-control-switches.tsx @@ -132,7 +132,7 @@ const ProxyControlSwitches = ({ const { t } = useTranslation() const { verge, mutateVerge, patchVerge } = useVerge() const { installServiceAndRestartCore } = useServiceInstaller() - const { uninstallServiceAndRestartCore } = useServiceUninstaller() + const { uninstallServiceAndStartSidecar } = useServiceUninstaller() const { indicator: systemProxyIndicator, toggleSystemProxy } = useSystemProxyState() const { isServiceOk, isTunModeAvailable, mutateSystemState } = @@ -169,11 +169,7 @@ const ProxyControlSwitches = ({ const onUninstallService = useLockFn(async () => { try { - if (verge?.enable_tun_mode) { - await handleTunToggle(false) - } - await uninstallServiceAndRestartCore() - await mutateSystemState() + await uninstallServiceAndStartSidecar() } catch (err) { showNotice.error(err) } diff --git a/src/hooks/use-service-uninstaller.ts b/src/hooks/use-service-uninstaller.ts index c3ed50027..769c47c2a 100644 --- a/src/hooks/use-service-uninstaller.ts +++ b/src/hooks/use-service-uninstaller.ts @@ -1,46 +1,33 @@ import { useCallback } from 'react' -import { restartCore, stopCore, uninstallService } from '@/services/cmds' +import { uninstallService } from '@/services/cmds' import { showNotice } from '@/services/notice-service' -const executeWithErrorHandling = async ( - operation: () => Promise, - loadingKey: string, - successKey?: string, -) => { - try { - showNotice.info(loadingKey) - await operation() - if (successKey) { - showNotice.success(successKey) - } - } catch (err) { - showNotice.error(err) - throw err - } -} +import { useSystemState } from './use-system-state' export const useServiceUninstaller = () => { - const uninstallServiceAndRestartCore = useCallback(async () => { + const { mutateSystemState } = useSystemState() + + const uninstallServiceAndStartSidecar = useCallback(async () => { + let uninstallError: unknown + showNotice.info('settings.statuses.clashService.uninstalling') try { - await executeWithErrorHandling( - () => stopCore(), - 'settings.statuses.clash.stopping', - ) - await executeWithErrorHandling( - () => uninstallService(), - 'settings.statuses.clashService.uninstalling', + await uninstallService() + showNotice.success( 'settings.feedback.notifications.clashService.uninstallSuccess', ) - } catch (ignore) { - } finally { - await executeWithErrorHandling( - () => restartCore(), - 'settings.statuses.clash.restarting', - 'settings.feedback.notifications.clash.restartSuccess', - ) + } catch (error) { + uninstallError = error } - }, []) - return { uninstallServiceAndRestartCore } + try { + await mutateSystemState() + } catch (error) { + if (!uninstallError) throw error + } + + if (uninstallError) throw uninstallError + }, [mutateSystemState]) + + return { uninstallServiceAndStartSidecar } } diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index 01be4aabe..e8a0cb38a 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -3,7 +3,7 @@ import { closeAllConnections } from 'tauri-plugin-mihomo-api' import { useVerge } from '@/hooks/use-verge' import { useClashConfigData, useSystemData } from '@/providers/app-data-context' -import { getAutotemProxy } from '@/services/cmds' +import { getAutotemProxy, getEmbeddedServerPort } from '@/services/cmds' import { revalidateQueries, useQuery } from '@/services/query-client' // 系统代理状态检测统一逻辑 @@ -17,6 +17,10 @@ export const useSystemProxyState = () => { refetchOnWindowFocus: true, refetchOnReconnect: true, }) + const { data: pacPort } = useQuery({ + queryKey: ['getEmbeddedServerPort'], + queryFn: getEmbeddedServerPort, + }) const { enable_system_proxy, @@ -30,7 +34,7 @@ export const useSystemProxyState = () => { const host = proxy_host || '127.0.0.1' if (proxy_auto_config) { if (!autoproxy?.enable) return false - const pacPort = import.meta.env.DEV ? 11233 : 33331 + if (!pacPort) return false return autoproxy.url === `http://${host}:${pacPort}/commands/pac` } else { if (!sysproxy?.enable) return false diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index c62fe2ed8..c41d7a0ac 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -1,24 +1,58 @@ import { useEffect, useRef, useState } from 'react' -import { getRunningMode, isAdmin, isServiceAvailable } from '@/services/cmds' +import { + getRunningMode, + getServiceInstallState, + isAdmin, + isServiceAvailable, + type RunningMode, + type ServiceInstallState, +} from '@/services/cmds' import { showNotice } from '@/services/notice-service' import { useQuery } from '@/services/query-client' import { useVerge } from './use-verge' import { useVisibility } from './use-visibility' -interface SystemState { - runningMode: 'Sidecar' | 'Service' +export interface SystemState { + runningMode: RunningMode isAdminMode: boolean isServiceOk: boolean + serviceInstallState: ServiceInstallState | null } const defaultSystemState = { runningMode: 'Sidecar', isAdminMode: false, isServiceOk: false, + serviceInstallState: null, } as SystemState +export const fetchSystemState = async (): Promise => { + const [runningMode, isAdminMode, isServiceOk, serviceInstallState] = + await Promise.all([ + getRunningMode(), + isAdmin(), + isServiceAvailable(), + getServiceInstallState().catch(() => null), + ]) + return { + runningMode, + isAdminMode, + isServiceOk, + serviceInstallState, + } +} + +const isServiceChoicePending = ( + state: ServiceInstallState | null, + serviceAvailable: boolean, + queryFailed: boolean, +) => + queryFailed || + state === 'sidecarAllowed' || + !(state === 'notInstalled' || (state === 'ready' && serviceAvailable)) + // Grace period for service initialization during startup const STARTUP_GRACE_MS = 10_000 @@ -39,16 +73,10 @@ export function useSystemState() { data: systemState = defaultSystemState, refetch: mutateSystemState, isLoading, + error: systemStateError, } = useQuery({ queryKey: ['getSystemState'], - queryFn: async () => { - const [runningMode, isAdminMode, isServiceOk] = await Promise.all([ - getRunningMode(), - isAdmin(), - isServiceAvailable(), - ]) - return { runningMode, isAdminMode, isServiceOk } as SystemState - }, + queryFn: fetchSystemState, refetchInterval: pageVisible ? (isStartingUp ? 2000 : 30000) : false, refetchOnWindowFocus: true, refetchOnReconnect: true, @@ -57,6 +85,11 @@ export function useSystemState() { const isSidecarMode = systemState.runningMode === 'Sidecar' const isServiceMode = systemState.runningMode === 'Service' const isTunModeAvailable = systemState.isAdminMode || systemState.isServiceOk + const serviceChoicePending = isServiceChoicePending( + systemState.serviceInstallState, + systemState.isServiceOk, + systemStateError !== undefined, + ) return { runningMode: systemState.runningMode, @@ -65,6 +98,7 @@ export function useSystemState() { isSidecarMode, isServiceMode, isTunModeAvailable, + serviceChoicePending, mutateSystemState, isLoading, isStartingUp, @@ -73,7 +107,8 @@ export function useSystemState() { export function useTunAvailabilityGuard() { const { verge, patchVerge } = useVerge() - const { isTunModeAvailable, isLoading, isStartingUp } = useSystemState() + const { isTunModeAvailable, serviceChoicePending, isLoading, isStartingUp } = + useSystemState() const disablingTunRef = useRef(false) const cooldownTimerRef = useRef | null>(null) const enable_tun_mode = verge?.enable_tun_mode @@ -85,6 +120,7 @@ export function useTunAvailabilityGuard() { !disablingTunRef.current && enable_tun_mode && !isTunModeAvailable && + !serviceChoicePending && !isLoading && !isStartingUp ) { @@ -117,5 +153,12 @@ export function useTunAvailabilityGuard() { disablingTunRef.current = false } } - }, [enable_tun_mode, isTunModeAvailable, patchVerge, isLoading, isStartingUp]) + }, [ + enable_tun_mode, + isTunModeAvailable, + serviceChoicePending, + patchVerge, + isLoading, + isStartingUp, + ]) } diff --git a/src/locales/ar/layout.json b/src/locales/ar/layout.json index 5c60990e7..3a6cdb1e8 100644 --- a/src/locales/ar/layout.json +++ b/src/locales/ar/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "Collapse navigation bar", "expandNavBar": "Expand navigation bar" } + }, + "serviceMigration": { + "title": "Service update required", + "message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.", + "checkingMessage": "Checking the system service status and current running mode. Please wait…", + "unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", + "reinstall": "Reinstall service", + "repair": "Repair service", + "continueSidecar": "Continue with Sidecar", + "success": "The Clash Verge service was updated successfully" } } } diff --git a/src/locales/de/layout.json b/src/locales/de/layout.json index c4c125626..2684b705d 100644 --- a/src/locales/de/layout.json +++ b/src/locales/de/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "Collapse navigation bar", "expandNavBar": "Expand navigation bar" } + }, + "serviceMigration": { + "title": "Service update required", + "message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.", + "checkingMessage": "Checking the system service status and current running mode. Please wait…", + "unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", + "reinstall": "Reinstall service", + "repair": "Repair service", + "continueSidecar": "Continue with Sidecar", + "success": "The Clash Verge service was updated successfully" } } } diff --git a/src/locales/en/layout.json b/src/locales/en/layout.json index 46e69ad94..49762e8b6 100644 --- a/src/locales/en/layout.json +++ b/src/locales/en/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "Collapse navigation bar", "expandNavBar": "Expand navigation bar" } + }, + "serviceMigration": { + "title": "Service update required", + "message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.", + "checkingMessage": "Checking the system service status and current running mode. Please wait…", + "unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", + "reinstall": "Reinstall service", + "repair": "Repair service", + "continueSidecar": "Continue with Sidecar", + "success": "The Clash Verge service was updated successfully" } } } diff --git a/src/locales/es/layout.json b/src/locales/es/layout.json index b15af2e44..d1ec64481 100644 --- a/src/locales/es/layout.json +++ b/src/locales/es/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "Collapse navigation bar", "expandNavBar": "Expand navigation bar" } + }, + "serviceMigration": { + "title": "Service update required", + "message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.", + "checkingMessage": "Checking the system service status and current running mode. Please wait…", + "unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", + "reinstall": "Reinstall service", + "repair": "Repair service", + "continueSidecar": "Continue with Sidecar", + "success": "The Clash Verge service was updated successfully" } } } diff --git a/src/locales/fa/layout.json b/src/locales/fa/layout.json index dcc5ba491..be65e60b2 100644 --- a/src/locales/fa/layout.json +++ b/src/locales/fa/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "Collapse navigation bar", "expandNavBar": "Expand navigation bar" } + }, + "serviceMigration": { + "title": "Service update required", + "message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.", + "checkingMessage": "Checking the system service status and current running mode. Please wait…", + "unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", + "reinstall": "Reinstall service", + "repair": "Repair service", + "continueSidecar": "Continue with Sidecar", + "success": "The Clash Verge service was updated successfully" } } } diff --git a/src/locales/id/layout.json b/src/locales/id/layout.json index 73594d833..94e38a0ce 100644 --- a/src/locales/id/layout.json +++ b/src/locales/id/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "Collapse navigation bar", "expandNavBar": "Expand navigation bar" } + }, + "serviceMigration": { + "title": "Service update required", + "message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.", + "checkingMessage": "Checking the system service status and current running mode. Please wait…", + "unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", + "reinstall": "Reinstall service", + "repair": "Repair service", + "continueSidecar": "Continue with Sidecar", + "success": "The Clash Verge service was updated successfully" } } } diff --git a/src/locales/jp/layout.json b/src/locales/jp/layout.json index d05e99ebc..b18b4d625 100644 --- a/src/locales/jp/layout.json +++ b/src/locales/jp/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "Collapse navigation bar", "expandNavBar": "Expand navigation bar" } + }, + "serviceMigration": { + "title": "Service update required", + "message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.", + "checkingMessage": "Checking the system service status and current running mode. Please wait…", + "unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", + "reinstall": "Reinstall service", + "repair": "Repair service", + "continueSidecar": "Continue with Sidecar", + "success": "The Clash Verge service was updated successfully" } } } diff --git a/src/locales/ko/layout.json b/src/locales/ko/layout.json index 2d5794270..2caa7d921 100644 --- a/src/locales/ko/layout.json +++ b/src/locales/ko/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "Collapse navigation bar", "expandNavBar": "Expand navigation bar" } + }, + "serviceMigration": { + "title": "Service update required", + "message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.", + "checkingMessage": "Checking the system service status and current running mode. Please wait…", + "unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", + "reinstall": "Reinstall service", + "repair": "Repair service", + "continueSidecar": "Continue with Sidecar", + "success": "The Clash Verge service was updated successfully" } } } diff --git a/src/locales/ru/layout.json b/src/locales/ru/layout.json index 7676fcf5b..aa9f92603 100644 --- a/src/locales/ru/layout.json +++ b/src/locales/ru/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "Свернуть панель навигации", "expandNavBar": "Развернуть панель навигации" } + }, + "serviceMigration": { + "title": "Service update required", + "message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.", + "checkingMessage": "Checking the system service status and current running mode. Please wait…", + "unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", + "reinstall": "Reinstall service", + "repair": "Repair service", + "continueSidecar": "Continue with Sidecar", + "success": "The Clash Verge service was updated successfully" } } } diff --git a/src/locales/tr/layout.json b/src/locales/tr/layout.json index 8ae201955..048e3149c 100644 --- a/src/locales/tr/layout.json +++ b/src/locales/tr/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "Collapse navigation bar", "expandNavBar": "Expand navigation bar" } + }, + "serviceMigration": { + "title": "Service update required", + "message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.", + "checkingMessage": "Checking the system service status and current running mode. Please wait…", + "unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", + "reinstall": "Reinstall service", + "repair": "Repair service", + "continueSidecar": "Continue with Sidecar", + "success": "The Clash Verge service was updated successfully" } } } diff --git a/src/locales/tt/layout.json b/src/locales/tt/layout.json index a39c5ba79..3a879ce55 100644 --- a/src/locales/tt/layout.json +++ b/src/locales/tt/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "Collapse navigation bar", "expandNavBar": "Expand navigation bar" } + }, + "serviceMigration": { + "title": "Service update required", + "message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.", + "checkingMessage": "Checking the system service status and current running mode. Please wait…", + "unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", + "reinstall": "Reinstall service", + "repair": "Repair service", + "continueSidecar": "Continue with Sidecar", + "success": "The Clash Verge service was updated successfully" } } } diff --git a/src/locales/zh/layout.json b/src/locales/zh/layout.json index 8340ff196..12984f766 100644 --- a/src/locales/zh/layout.json +++ b/src/locales/zh/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "收起导航栏", "expandNavBar": "展开导航栏" } + }, + "serviceMigration": { + "title": "需要更新系统服务", + "message": "检测到旧版 Clash Verge 系统服务。重新安装后可使用当前安全 IPC 协议,也可以仅在本次会话继续使用 Sidecar。", + "checkingMessage": "正在确认系统服务状态及当前运行模式,请稍候……", + "unavailableMessage": "所需的 Clash Verge 系统服务当前不可用。你可以安装或修复服务,或仅在本次会话继续使用 Sidecar。", + "reinstall": "重新安装服务", + "repair": "修复服务", + "continueSidecar": "继续使用 Sidecar", + "success": "Clash Verge 系统服务已更新" } } } diff --git a/src/locales/zhtw/layout.json b/src/locales/zhtw/layout.json index 0521dcd30..2dc7a7845 100644 --- a/src/locales/zhtw/layout.json +++ b/src/locales/zhtw/layout.json @@ -19,6 +19,16 @@ "collapseNavBar": "收起導覽列", "expandNavBar": "展開導覽列" } + }, + "serviceMigration": { + "title": "需要更新系統服務", + "message": "偵測到舊版 Clash Verge 系統服務。重新安裝後可使用目前的安全 IPC 協定,也可以僅在本次工作階段繼續使用 Sidecar。", + "checkingMessage": "正在確認系統服務狀態及目前執行模式,請稍候……", + "unavailableMessage": "所需的 Clash Verge 系統服務目前不可用。你可以安裝或修復服務,或僅在本次工作階段繼續使用 Sidecar。", + "reinstall": "重新安裝服務", + "repair": "修復服務", + "continueSidecar": "繼續使用 Sidecar", + "success": "Clash Verge 系統服務已更新" } } } diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 0766bf226..638524af4 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -35,6 +35,7 @@ import { BaseErrorBoundary } from '@/components/base' import { LayoutItem } from '@/components/layout/layout-item' import { LayoutTraffic } from '@/components/layout/layout-traffic' import { NoticeManager } from '@/components/layout/notice-manager' +import { ServiceMigrationDialog } from '@/components/layout/service-migration-dialog' import { UpdateButton } from '@/components/layout/update-button' import { WindowControls, @@ -262,6 +263,7 @@ const Layout = () => { {/* 左侧底部窗口控制按钮 */} +
('change_clash_core', { clashCore }) +export async function getEmbeddedServerPort() { + return invoke('get_embedded_server_port') } -export async function stopCore() { - return invoke('stop_core') +export async function changeClashCore(clashCore: string) { + return invoke('change_clash_core', { clashCore }) } export async function restartCore() { @@ -358,8 +358,10 @@ export async function listLocalBackup() { } // 获取当前运行模式 +export type RunningMode = 'Service' | 'Sidecar' | 'NotRunning' + export const getRunningMode = async () => { - return invoke('get_running_mode') + return invoke('get_running_mode') } // 获取应用运行时间 @@ -377,12 +379,36 @@ export const uninstallService = async () => { return invoke('uninstall_service') } +export type ServiceInstallState = + | 'checking' + | 'notInstalled' + | 'installRequired' + | 'ready' + | 'needsReinstall' + | 'sidecarAllowed' + | 'unavailable' + +export const getServiceInstallState = async () => { + return invoke('get_service_install_state') +} + +export const reinstallService = async () => { + return invoke('reinstall_service') +} + +export const repairService = async () => { + return invoke('repair_service') +} + +export const continueWithSidecar = async () => { + return invoke('continue_with_sidecar') +} + // 系统服务是否可用 export const isServiceAvailable = async () => { try { return await invoke('is_service_available') - } catch (error) { - console.error('Service check failed:', error) + } catch { return false } } diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 41a00f852..ceb8312c9 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -109,6 +109,14 @@ export const translationKeys = [ 'layout.components.navigation.menu.lock', 'layout.components.navigation.menu.collapseNavBar', 'layout.components.navigation.menu.expandNavBar', + 'layout.components.serviceMigration.title', + 'layout.components.serviceMigration.message', + 'layout.components.serviceMigration.checkingMessage', + 'layout.components.serviceMigration.unavailableMessage', + 'layout.components.serviceMigration.reinstall', + 'layout.components.serviceMigration.repair', + 'layout.components.serviceMigration.continueSidecar', + 'layout.components.serviceMigration.success', 'logs.page.title', 'logs.actions.showDescending', 'logs.actions.showAscending', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index cd3b3dd59..3c822e777 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -194,6 +194,16 @@ export interface TranslationResources { unlock: string } } + serviceMigration: { + checkingMessage: string + continueSidecar: string + message: string + reinstall: string + repair: string + success: string + title: string + unavailableMessage: string + } } } logs: {