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: {