mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
feat: add launch guard and secure multi-user service ownership (#7569)
* feat(service): isolate macOS launch and owner runtime * ci(windows): run owner security tests * chore(deps): use kode-bridge v0.4.1 tag * fix(macos): classify installed service state * fix(macos): classify marked unavailable service * fix(macos): gate core startup on service migration * fix(macos): scope service init to supported platforms * feat(macos): prompt before migrating installed service * fix(macos): shorten sidecar IPC path * build(service): pin helper protocol and artifacts * fix(core): require confirmed service and sidecar readiness * fix(macos): harden launch roots and singleton state * fix(ui): refresh migration state before restart * fix(macos): linearize service migration lifecycle * fix(ui): keep service migration state fresh * feat(service): track authenticated owner sessions * fix(service): preserve newer owner sessions * feat(macos): route service proxy changes through helper * fix(macos): serialize service proxy guard * fix(macos): close service proxy owner lifecycle * fix(macos): close remaining proxy stop races * fix(app): do not cancel controlled service stop * fix(lifecycle): keep controlled updates cancellation-safe * fix(macos): stage dev core for service * test(macos): exercise staged core cleanup * fix(dev): await controlled macOS exit * chore: update changelog * fix(macos): recover sidecar after service install failure Allow the existing sidecar to continue when service installation leaves the manager in InstallRequired, then settle the session state as SidecarAllowed. Show neutral status text while the migration dialog waits for its final state. * fix(macos): stage dev service tools before elevation * fix(macos): localize launch guard prompts * fix(service): address cross-platform integration issues * feat: support seamless service migration * fix: pass Windows service installer path safely * fix(prebuild): derive service release from Cargo dependency * fix(dev): preserve service installation state * fix: resolve clippy warnings in service lifecycle
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+39
-19
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
] }
|
||||
|
||||
@@ -12,5 +12,10 @@
|
||||
|
||||
- TUN 模式设置也遵循 原始配置 < Merge 覆写 < Script 脚本 < GUI
|
||||
- 优化代理数据处理:统一通过单一 IPC 获取有序代理视图,并增强刷新、链路选择、重复判断及异常状态下的稳定性
|
||||
- 新增 macOS 启动门禁,避免多用户环境下的实例冲突
|
||||
- 加强 Service 多用户隔离与身份认证,支持安全的 Owner 接管
|
||||
- 统一系统代理所有权,实现原子化代理切换
|
||||
- 新增旧版 Service 迁移引导与 Sidecar 安全回退
|
||||
- 优化核心启动、停止及更新流程,减少竞态与残留进程
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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: وضع القواعد
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: حالت قوانین
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ルールモード
|
||||
|
||||
@@ -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: 규칙 모드
|
||||
|
||||
@@ -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: Режим правил
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: Кагыйдә режимы
|
||||
|
||||
@@ -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: 规则模式
|
||||
|
||||
@@ -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: 規則模式
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Option<tokio::runtime::Runtime>> = 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, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future + Send + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future + Send + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + 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 {
|
||||
|
||||
@@ -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, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future + Send + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + 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 {
|
||||
|
||||
+6
-4
@@ -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",
|
||||
|
||||
Generated
-10
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
+565
@@ -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 <dev|trace|sidecar>')
|
||||
}
|
||||
|
||||
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 <dev|trace|sidecar>')
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
+10
-63
@@ -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)
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
@@ -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/,
|
||||
)
|
||||
})
|
||||
+14
-4
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -60,6 +60,11 @@ pub async fn get_auto_proxy() -> CmdResult<Mapping> {
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_embedded_server_port() -> CmdResult<u16> {
|
||||
crate::utils::server::embedded_server_port().stringify_err()
|
||||
}
|
||||
|
||||
/// 获取系统主机名
|
||||
#[tauri::command]
|
||||
pub fn get_system_hostname() -> String {
|
||||
|
||||
@@ -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<E: std::fmt::Display>(
|
||||
Ok(ValidationOutcome::invalid_from_message(message))
|
||||
}
|
||||
|
||||
async fn handle_timeout(current_profile: Option<&String>) -> CmdResult<ValidationOutcome> {
|
||||
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, UpdateFuture>(
|
||||
update_config: Update,
|
||||
) -> anyhow::Result<ValidationOutcome>
|
||||
where
|
||||
Update: FnOnce() -> UpdateFuture,
|
||||
UpdateFuture: std::future::Future<Output = anyhow::Result<ValidationOutcome>>,
|
||||
{
|
||||
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<Option<i64>> {
|
||||
|
||||
#[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<AtomicBool>,
|
||||
completed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> {
|
||||
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<service::ServiceInstallState> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
@@ -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<IRuntime>,
|
||||
}
|
||||
|
||||
static TUN_SESSION_SUPPRESSED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
impl Config {
|
||||
pub async fn global() -> &'static Self {
|
||||
static CONFIG: OnceCell<Config> = 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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<WriterConfig> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Arc<AsyncLogger>> = 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<u64> {
|
||||
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<OwnedHandle>,
|
||||
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<u32> {
|
||||
self.state.load().child_sidecar.load_full().map(|child| child.pid())
|
||||
}
|
||||
|
||||
pub fn get_last_update(&self) -> Option<Arc<Instant>> {
|
||||
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<u64> {
|
||||
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<bool> {
|
||||
self.start_core().await?;
|
||||
Ok(())
|
||||
Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<F, Fut>(
|
||||
max_attempts: usize,
|
||||
retry_delay: std::time::Duration,
|
||||
mut probe: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
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<u32>, 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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<OwnerCredentials> {
|
||||
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<OwnerIdentity> {
|
||||
#[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<OwnerCredentials> {
|
||||
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<String>)> {
|
||||
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<OwnerIdentity> {
|
||||
Ok(OwnerIdentity::Windows {
|
||||
sid: windows_owner::current_sid()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::create_private_file(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn open_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::open_private_file(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn open_or_create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::open_or_create_private_file(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn current_user_pipe_sddl() -> Result<String> {
|
||||
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<String> {
|
||||
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::<usize>());
|
||||
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::<TOKEN_USER>() };
|
||||
sid_to_string(token_user.User.Sid)
|
||||
}
|
||||
|
||||
pub(super) fn load_or_create_token(app_data_root: &Path, sid: &str) -> Result<String> {
|
||||
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::<SECURITY_ATTRIBUTES>() 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<std::fs::File> {
|
||||
let sid = current_sid()?;
|
||||
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
|
||||
create_file(path, &descriptor)
|
||||
}
|
||||
|
||||
pub(super) fn open_private_file(path: &Path) -> Result<std::fs::File> {
|
||||
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<std::fs::File> {
|
||||
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::<std::io::Error>()
|
||||
.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<std::fs::File> {
|
||||
let attributes = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() 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<std::fs::File> {
|
||||
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::<ACCESS_ALLOWED_ACE>() };
|
||||
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::<c_void>();
|
||||
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<String> {
|
||||
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<Vec<u16>> {
|
||||
let mut wide: Vec<u16> = 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<Self> {
|
||||
let sddl = format!("O:{sid}D:P(A;;FA;;;{sid})(A;;FA;;;SY)(A;;FA;;;BA)");
|
||||
let mut wide: Vec<u16> = 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<PSID> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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,<local>";
|
||||
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<F, Fut, T>(&self, operation: F) -> T
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = T>,
|
||||
{
|
||||
let _operation = self.operation_lock.lock().await;
|
||||
operation().await
|
||||
}
|
||||
|
||||
async fn run_final_service_operation<F, Fut, T>(&self, operation: F) -> T
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = T>,
|
||||
{
|
||||
self.invalidate_guard();
|
||||
self.run_service_operation(operation).await
|
||||
}
|
||||
|
||||
async fn cancel_and_drain<F, Fut>(&self, cancel: F) -> u64
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
let generation = self.invalidate_guard();
|
||||
self.run_service_operation(cancel).await;
|
||||
generation
|
||||
}
|
||||
|
||||
async fn run_guard_request<Mode, ActiveProof, Request, RequestFuture>(
|
||||
&self,
|
||||
captured_generation: u64,
|
||||
captured_proof: &OwnerSessionProof,
|
||||
is_service_mode: Mode,
|
||||
active_proof: ActiveProof,
|
||||
request: Request,
|
||||
) -> Result<bool>
|
||||
where
|
||||
Mode: FnOnce() -> bool,
|
||||
ActiveProof: FnOnce() -> Result<OwnerSessionProof>,
|
||||
Request: FnOnce() -> RequestFuture,
|
||||
RequestFuture: Future<Output = Result<ProxyApplyOutcome>>,
|
||||
{
|
||||
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<String> {
|
||||
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<MacosProxyConfig> {
|
||||
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<MacosProxyConfig> {
|
||||
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()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<RuntimeBundle> {
|
||||
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<String>,
|
||||
assets: &mut Vec<RuntimeAsset>,
|
||||
) -> 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<PathBuf> {
|
||||
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<String> {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
+1866
-263
File diff suppressed because it is too large
Load Diff
@@ -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<RwLock<(Sysproxy, Autoproxy)>>,
|
||||
guard: Arc<RwLock<GuardMonitor>>,
|
||||
@@ -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) = {
|
||||
|
||||
+127
-26
@@ -36,6 +36,24 @@ struct TaskState {
|
||||
running: bool,
|
||||
}
|
||||
|
||||
async fn run_timer_profile_update_transition<Started, Update, UpdateFuture, Finished, Completed, Output>(
|
||||
update_started: Started,
|
||||
update_profile: Update,
|
||||
update_finished: Finished,
|
||||
update_completed: Completed,
|
||||
) where
|
||||
Started: FnOnce(),
|
||||
Update: FnOnce() -> UpdateFuture,
|
||||
UpdateFuture: std::future::Future<Output = Output>,
|
||||
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<AtomicBool>,
|
||||
completed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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<bool>, update_interval: Option<u64>) -> 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+352
-114
@@ -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, StopFuture, Ancillary, AncillaryFuture>(
|
||||
stop_core: Stop,
|
||||
ancillary_cleanup: Ancillary,
|
||||
) -> CleanupResult
|
||||
where
|
||||
Stop: FnOnce() -> StopFuture,
|
||||
StopFuture: std::future::Future<Output = bool>,
|
||||
Ancillary: FnOnce() -> AncillaryFuture,
|
||||
AncillaryFuture: std::future::Future<Output = bool>,
|
||||
{
|
||||
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, StopFuture, Ancillary, AncillaryFuture>(
|
||||
stop_core: Stop,
|
||||
ancillary_cleanup: Ancillary,
|
||||
) -> CleanupResult
|
||||
where
|
||||
Stop: FnOnce() -> StopFuture,
|
||||
StopFuture: std::future::Future<Output = bool>,
|
||||
Ancillary: FnOnce() -> AncillaryFuture,
|
||||
AncillaryFuture: std::future::Future<Output = bool>,
|
||||
{
|
||||
run_exit_cleanup_transition(stop_core, ancillary_cleanup).await
|
||||
}
|
||||
|
||||
async fn run_session_ending_cleanup_transition<Stop, StopFuture, DeadlineFuture, Ancillary, AncillaryFuture>(
|
||||
stop_core: Stop,
|
||||
stop_deadline: DeadlineFuture,
|
||||
ancillary_cleanup: Ancillary,
|
||||
) -> CleanupResult
|
||||
where
|
||||
Stop: FnOnce() -> StopFuture,
|
||||
StopFuture: std::future::Future<Output = bool>,
|
||||
DeadlineFuture: std::future::Future<Output = ()>,
|
||||
Ancillary: FnOnce() -> AncillaryFuture,
|
||||
AncillaryFuture: std::future::Future<Output = bool>,
|
||||
{
|
||||
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<AtomicBool>,
|
||||
completed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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::<bool>().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"]);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-3
@@ -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");
|
||||
}),
|
||||
|
||||
+189
-31
@@ -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<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preinit_app_data_dir() -> Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
// 避免在Handle未初始化时崩溃
|
||||
@@ -213,40 +233,155 @@ pub fn get_encryption_key() -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn ensure_mihomo_safe_dir() -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
Ok(PathBuf::from(clash_verge_service_ipc::mihomo_ipc_path(
|
||||
&crate::core::owner_identity::current_owner_identity()?,
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn ipc_path() -> Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
sidecar_ipc_path_for(
|
||||
std::path::Path::new(""),
|
||||
&crate::core::owner_identity::current_owner_identity()?,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn ipc_path() -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(r"\\.\pipe\verge-mihomo"))
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn sidecar_ipc_path() -> Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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::<libc::passwd>::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<Option<PathBuf>> {
|
||||
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<std::path::PathBuf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+545
-59
@@ -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<Mutex<Option<oneshot::Sender<()>>>> = OnceCell::new();
|
||||
static EMBEDDED_PORT: OnceCell<u16> = OnceCell::new();
|
||||
static INSTANCE_LOCK: OnceCell<std::fs::File> = 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::string::String> = 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<u16>) -> Result<tokio::net::TcpListener> {
|
||||
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<Extract = (), Error = warp::Rejection> + Clone {
|
||||
warp::header::optional::<String>(INSTANCE_TOKEN_HEADER)
|
||||
.and_then(move |provided: Option<String>| {
|
||||
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<F>(
|
||||
token: String,
|
||||
commands_ready: &'static AtomicBool,
|
||||
quit_requested: &'static AtomicBool,
|
||||
trigger: F,
|
||||
) -> impl warp::Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + 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::<std::string::String>(
|
||||
"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::<QueryParam>())
|
||||
.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::<std::string::String>(
|
||||
"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<u16> {
|
||||
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<PathBuf> {
|
||||
Ok(dirs::preinit_app_data_dir()?.join(INSTANCE_RECORD_FILE))
|
||||
}
|
||||
|
||||
fn open_instance_lock(path: &Path) -> Result<std::fs::File> {
|
||||
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<bool> {
|
||||
#[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<String> {
|
||||
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<InstanceRecord> {
|
||||
#[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<u16> = source.as_os_str().encode_wide().collect();
|
||||
source.push(0);
|
||||
let mut destination: Vec<u16> = 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ServiceInstallState>(installStateQueryKey, data)
|
||||
setStateRefreshFailed(false)
|
||||
return data
|
||||
} catch (error) {
|
||||
setStateRefreshFailed(true)
|
||||
await setCacheDataAsync<ServiceInstallState>(
|
||||
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 (
|
||||
<BaseDialog
|
||||
open={open}
|
||||
title={t('layout.components.serviceMigration.title')}
|
||||
okBtn={t(
|
||||
dialogState === 'installRequired'
|
||||
? 'settings.sections.proxyControl.actions.installService'
|
||||
: dialogState === 'unavailable'
|
||||
? 'layout.components.serviceMigration.repair'
|
||||
: 'layout.components.serviceMigration.reinstall',
|
||||
)}
|
||||
cancelBtn={t('layout.components.serviceMigration.continueSidecar')}
|
||||
disableOk={loading}
|
||||
disableCancel={loading}
|
||||
loading={loading}
|
||||
onOk={() => void handleServiceAction()}
|
||||
onCancel={() => void handleContinue()}
|
||||
>
|
||||
<Alert severity="warning">
|
||||
{t(
|
||||
showCheckingMessage
|
||||
? 'layout.components.serviceMigration.checkingMessage'
|
||||
: dialogState === 'installRequired' || dialogState === 'unavailable'
|
||||
? 'layout.components.serviceMigration.unavailableMessage'
|
||||
: 'layout.components.serviceMigration.message',
|
||||
)}
|
||||
</Alert>
|
||||
</BaseDialog>
|
||||
)
|
||||
}
|
||||
@@ -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<DialogRef>((props, ref) => {
|
||||
pac_content: pac_file_content ?? DEFAULT_PAC,
|
||||
proxy_host: proxy_host ?? '127.0.0.1',
|
||||
})
|
||||
const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
getEmbeddedServerPort().then(setEmbeddedServerPort).catch(console.error)
|
||||
}, [])
|
||||
|
||||
const separator = useMemo(() => (isWindows ? ';' : ','), [isWindows])
|
||||
|
||||
@@ -203,10 +211,10 @@ export const SysproxyViewer = forwardRef<DialogRef>((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 &&
|
||||
|
||||
@@ -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<DialogRef> }) {
|
||||
|
||||
try {
|
||||
await updateInfo.downloadAndInstall(onDownloadEvent)
|
||||
await relaunch()
|
||||
await restartApp()
|
||||
} catch (err: any) {
|
||||
showNotice.error(err)
|
||||
} finally {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<void>,
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<SystemState> => {
|
||||
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<ReturnType<typeof setTimeout> | 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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 系统服务已更新"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 系統服務已更新"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* 左侧底部窗口控制按钮 */}
|
||||
<NoticeManager position={verge?.notice_position} />
|
||||
<ServiceMigrationDialog />
|
||||
<div
|
||||
style={{
|
||||
animation: 'fadeIn 0.5s',
|
||||
|
||||
+33
-7
@@ -190,12 +190,12 @@ export async function getAutotemProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeClashCore(clashCore: string) {
|
||||
return invoke<string | null>('change_clash_core', { clashCore })
|
||||
export async function getEmbeddedServerPort() {
|
||||
return invoke<number>('get_embedded_server_port')
|
||||
}
|
||||
|
||||
export async function stopCore() {
|
||||
return invoke<void>('stop_core')
|
||||
export async function changeClashCore(clashCore: string) {
|
||||
return invoke<string | null>('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<string>('get_running_mode')
|
||||
return invoke<RunningMode>('get_running_mode')
|
||||
}
|
||||
|
||||
// 获取应用运行时间
|
||||
@@ -377,12 +379,36 @@ export const uninstallService = async () => {
|
||||
return invoke<void>('uninstall_service')
|
||||
}
|
||||
|
||||
export type ServiceInstallState =
|
||||
| 'checking'
|
||||
| 'notInstalled'
|
||||
| 'installRequired'
|
||||
| 'ready'
|
||||
| 'needsReinstall'
|
||||
| 'sidecarAllowed'
|
||||
| 'unavailable'
|
||||
|
||||
export const getServiceInstallState = async () => {
|
||||
return invoke<ServiceInstallState>('get_service_install_state')
|
||||
}
|
||||
|
||||
export const reinstallService = async () => {
|
||||
return invoke<void>('reinstall_service')
|
||||
}
|
||||
|
||||
export const repairService = async () => {
|
||||
return invoke<void>('repair_service')
|
||||
}
|
||||
|
||||
export const continueWithSidecar = async () => {
|
||||
return invoke<void>('continue_with_sidecar')
|
||||
}
|
||||
|
||||
// 系统服务是否可用
|
||||
export const isServiceAvailable = async () => {
|
||||
try {
|
||||
return await invoke<boolean>('is_service_available')
|
||||
} catch (error) {
|
||||
console.error('Service check failed:', error)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user