mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
fix(service): explain sidecar failures and clear recovered notices
This commit is contained in:
@@ -17,4 +17,8 @@
|
||||
|
||||
- 优化侧边栏流量图表的 CPU 占用
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
|
||||
|
||||
</details>
|
||||
|
||||
@@ -34,7 +34,10 @@ async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str)
|
||||
SERVICE_MANAGER.handle_service_status(status).await
|
||||
};
|
||||
#[cfg(windows)]
|
||||
return finish_service_installation(result, error_code, || manager.continue_with_sidecar()).await;
|
||||
return finish_service_installation(result, error_code, |reason| async move {
|
||||
manager.continue_with_sidecar(Some(&reason)).await
|
||||
})
|
||||
.await;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
result
|
||||
@@ -49,7 +52,7 @@ async fn finish_service_installation<Fallback, FallbackFuture>(
|
||||
fallback: Fallback,
|
||||
) -> CmdResult<ServiceInstallOutcome>
|
||||
where
|
||||
Fallback: FnOnce() -> FallbackFuture,
|
||||
Fallback: FnOnce(String) -> FallbackFuture,
|
||||
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
|
||||
{
|
||||
let Err(error) = installation else {
|
||||
@@ -72,12 +75,9 @@ where
|
||||
return Err(proxy_aware_coded_error(&error, error_code));
|
||||
}
|
||||
let reason = reasons.join("\n");
|
||||
fallback().await.map_err(|error| {
|
||||
proxy_aware_coded_error(
|
||||
&error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
|
||||
"SERVICE_SIDECAR_FAILED",
|
||||
)
|
||||
})?;
|
||||
fallback(reason.clone())
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?;
|
||||
Ok(ServiceInstallOutcome::Sidecar { reason })
|
||||
}
|
||||
|
||||
@@ -107,11 +107,16 @@ pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
|
||||
#[tauri::command]
|
||||
pub async fn continue_with_sidecar() -> CmdResult {
|
||||
crate::core::CoreManager::global()
|
||||
.continue_with_sidecar()
|
||||
.continue_with_sidecar(None)
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot {
|
||||
crate::core::notification::sidecar_failure_snapshot()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
|
||||
crate::core::service::take_service_fallback_notice()
|
||||
@@ -175,7 +180,7 @@ mod tests {
|
||||
None => anyhow::anyhow!("{reason}"),
|
||||
}
|
||||
.context("install service failed");
|
||||
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", || async {
|
||||
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", |_| async {
|
||||
fallback_called.set(true);
|
||||
anyhow::ensure!(fallback_ready, "another core is running");
|
||||
Ok(())
|
||||
@@ -201,7 +206,6 @@ mod tests {
|
||||
})
|
||||
);
|
||||
if rejected {
|
||||
assert!(error.detail.contains(reason));
|
||||
assert!(error.detail.contains("another core is running"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +334,18 @@ enum HandoffOutcome {
|
||||
Failed,
|
||||
}
|
||||
|
||||
fn report_sidecar_failure(error: anyhow::Error, service_rejection: Option<&str>) -> anyhow::Error {
|
||||
let error = match service_rejection {
|
||||
Some(reason) => error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
|
||||
None => error,
|
||||
};
|
||||
// Proxy failures have their own pending state and recovery dialog.
|
||||
if SysproxyFailure::from_chain(&error).is_none() {
|
||||
crate::core::notification::record_sidecar_failure(format!("{error:#}").into());
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
impl CoreManager {
|
||||
async fn rollback_failed_start(&self) {
|
||||
proxy_control::stop_guard().await;
|
||||
@@ -364,9 +376,12 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, level = "info", fields(status = tracing::field::Empty, tun_disabled = false, readiness_generation = tracing::field::Empty))]
|
||||
pub async fn continue_with_sidecar(&self) -> Result<()> {
|
||||
pub async fn continue_with_sidecar(&self, service_rejection: Option<&str>) -> Result<()> {
|
||||
if !self.try_start_config_update() {
|
||||
anyhow::bail!("configuration update is already running");
|
||||
return Err(report_sidecar_failure(
|
||||
anyhow::anyhow!("configuration update is already running"),
|
||||
service_rejection,
|
||||
));
|
||||
}
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
@@ -375,61 +390,66 @@ impl CoreManager {
|
||||
// draft without claiming it, so without this it can commit another transaction's staged patch.
|
||||
let config_write = Config::lock_config_write().await;
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
let status = SERVICE_MANAGER.current().await;
|
||||
tracing::Span::current().record("status", tracing::field::debug(&status));
|
||||
let mode = self.get_running_mode();
|
||||
if !can_allow_sidecar_for_session(&mode, &status) {
|
||||
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if matches!(*mode, RunningMode::NotRunning) {
|
||||
clash_verge_service_ipc::execution::check_sidecar_available().await?;
|
||||
}
|
||||
if !matches!(status, ServiceStatus::SidecarAllowed) {
|
||||
SERVICE_MANAGER.allow_sidecar_for_session()?;
|
||||
}
|
||||
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
|
||||
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
|
||||
let prepared = async {
|
||||
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
||||
if crate::core::runstate::RUN_STATE
|
||||
.state()
|
||||
.tun_should_be_disabled(tun_enabled)
|
||||
{
|
||||
tracing::Span::current().record("tun_disabled", true);
|
||||
Config::disable_tun_and_persist().await?;
|
||||
}
|
||||
Config::generate().await
|
||||
}
|
||||
.await;
|
||||
drop(config_write);
|
||||
if let Err(error) = prepared {
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
return Err(error);
|
||||
}
|
||||
// Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded.
|
||||
proxy_control::stop_guard().await;
|
||||
// Record failures before releasing the lifecycle lock, including preparation and rollback errors.
|
||||
let result = async {
|
||||
self.start_core_inner().await?;
|
||||
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|
||||
|| self.current_core_readiness_generation().is_none()
|
||||
{
|
||||
anyhow::bail!("Sidecar did not become ready");
|
||||
let status = SERVICE_MANAGER.current().await;
|
||||
tracing::Span::current().record("status", tracing::field::debug(&status));
|
||||
let mode = self.get_running_mode();
|
||||
if !can_allow_sidecar_for_session(&mode, &status) {
|
||||
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
|
||||
}
|
||||
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
||||
self.apply_proxy_after_start().await
|
||||
#[cfg(target_os = "windows")]
|
||||
if matches!(*mode, RunningMode::NotRunning) {
|
||||
clash_verge_service_ipc::execution::check_sidecar_available().await?;
|
||||
}
|
||||
if !matches!(status, ServiceStatus::SidecarAllowed) {
|
||||
SERVICE_MANAGER.allow_sidecar_for_session()?;
|
||||
}
|
||||
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
|
||||
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
|
||||
let prepared = async {
|
||||
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
||||
if crate::core::runstate::RUN_STATE
|
||||
.state()
|
||||
.tun_should_be_disabled(tun_enabled)
|
||||
{
|
||||
tracing::Span::current().record("tun_disabled", true);
|
||||
Config::disable_tun_and_persist().await?;
|
||||
}
|
||||
Config::generate().await
|
||||
}
|
||||
.await;
|
||||
drop(config_write);
|
||||
if let Err(error) = prepared {
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
return Err(error);
|
||||
}
|
||||
// Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded.
|
||||
proxy_control::stop_guard().await;
|
||||
let result = async {
|
||||
self.start_core_inner().await?;
|
||||
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|
||||
|| self.current_core_readiness_generation().is_none()
|
||||
{
|
||||
anyhow::bail!("Sidecar did not become ready");
|
||||
}
|
||||
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
||||
self.apply_proxy_after_start().await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
|
||||
return Err(proxy_control::rollback_failure(error, cleanup_error)
|
||||
.context("failed to clear the proxy before rolling back"));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
|
||||
return Err(proxy_control::rollback_failure(error, cleanup_error)
|
||||
.context("failed to clear the proxy before rolling back"));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
result.map_err(|error| report_sidecar_failure(error, service_rejection))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))]
|
||||
@@ -572,6 +592,7 @@ impl CoreManager {
|
||||
}
|
||||
// tell the window when a background apply lands
|
||||
Handle::refresh_verge();
|
||||
crate::core::notification::retire_sidecar_failure();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,46 @@ pub fn what_was_asked() -> FailedOperation {
|
||||
|
||||
static PENDING_FAILURES: Lazy<FailureTable> = Lazy::new(FailureTable::default);
|
||||
|
||||
/// A removal has a revision too, so a delayed read cannot restore a resolved failure.
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
pub struct SidecarFailureSnapshot {
|
||||
pub revision: u64,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
impl SidecarFailureSnapshot {
|
||||
fn record(&mut self, detail: String) {
|
||||
self.revision += 1;
|
||||
self.detail = Some(detail);
|
||||
}
|
||||
|
||||
fn recover(&mut self) -> bool {
|
||||
if self.detail.take().is_none() {
|
||||
return false;
|
||||
}
|
||||
self.revision += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
static SIDECAR_FAILURE: Lazy<Mutex<SidecarFailureSnapshot>> = Lazy::new(Mutex::default);
|
||||
|
||||
pub fn sidecar_failure_snapshot() -> SidecarFailureSnapshot {
|
||||
SIDECAR_FAILURE.lock().clone()
|
||||
}
|
||||
|
||||
pub fn record_sidecar_failure(detail: String) {
|
||||
SIDECAR_FAILURE.lock().record(detail);
|
||||
notify_pending_failures_changed();
|
||||
}
|
||||
|
||||
pub fn retire_sidecar_failure() {
|
||||
let changed = SIDECAR_FAILURE.lock().recover();
|
||||
if changed {
|
||||
notify_pending_failures_changed();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<String>) {
|
||||
PENDING_FAILURES.record(operation, code, detail.into());
|
||||
notify_pending_failures_changed();
|
||||
|
||||
@@ -143,6 +143,7 @@ mod app_init {
|
||||
cmd::upgrade_clash_core,
|
||||
cmd::get_runtime_state,
|
||||
cmd::get_pending_failures,
|
||||
cmd::get_sidecar_failure,
|
||||
cmd::entry_lightweight_mode,
|
||||
cmd::install_service,
|
||||
cmd::uninstall_service,
|
||||
|
||||
@@ -68,6 +68,66 @@ const resolveNoticeMessage = (
|
||||
const i18n = notice.i18n
|
||||
if (!i18n) return bound(notice.message)
|
||||
|
||||
const detail = i18n.params?.message
|
||||
const existingCore =
|
||||
notice.code === 'SERVICE_SIDECAR_FAILED' && typeof detail === 'string'
|
||||
? /process verge-mihomo(?:-alpha)?(?:\.exe)? \(PID (\d+)\) is still running; refusing a second core\s*$/.exec(
|
||||
detail,
|
||||
)
|
||||
: null
|
||||
if (existingCore && typeof detail === 'string') {
|
||||
const permissionRejected = detail.startsWith('Service core rejected:')
|
||||
return (
|
||||
<>
|
||||
{permissionRejected && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
{t(
|
||||
'settings.feedback.notifications.clashService.permissionRejectedReason',
|
||||
)}{' '}
|
||||
<Link
|
||||
href={SERVICE_PERMISSION_GUIDE}
|
||||
color="inherit"
|
||||
underline="always"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
void openExternalUrl(SERVICE_PERMISSION_GUIDE).catch(
|
||||
showNotice.error,
|
||||
)
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
'settings.feedback.notifications.clashService.permissionRepairGuide',
|
||||
)}
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
{t('settings.feedback.errors.clashService.coreAlreadyRunning', {
|
||||
pid: existingCore[1],
|
||||
})}
|
||||
<Box component="details" sx={{ mt: 1 }}>
|
||||
<Box component="summary" sx={{ cursor: 'pointer' }}>
|
||||
{t('shared.feedback.errors.details')}
|
||||
</Box>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
m: 0,
|
||||
mt: 1,
|
||||
maxHeight: 160,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'anywhere',
|
||||
userSelect: 'text',
|
||||
fontSize: 'inherit',
|
||||
}}
|
||||
>
|
||||
{detail}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (i18n.key === SERVICE_PERMISSION_NOTICE) {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "تعذّر إلغاء تثبيت الخدمة",
|
||||
"reinstallFailed": "تعذّر إعادة تثبيت الخدمة",
|
||||
"repairFailed": "تعذّر إصلاح الخدمة",
|
||||
"sidecarFailed": "تعذّر المتابعة باستخدام Sidecar"
|
||||
"sidecarFailed": "تعذّر المتابعة باستخدام Sidecar",
|
||||
"coreAlreadyRunning": "تعذّر بدء وضع Sidecar: لا تزال نواة أخرى قيد التشغيل (PID {{pid}}). أغلق نسخ Clash Verge الأخرى أو التطبيق الذي يستخدم هذه النواة، ثم أعد المحاولة."
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "تعذّر ضبط وكيل النظام: رفض macOS التغيير لعدم كفاية الصلاحيات. ثبّت خدمة Clash Verge؛ وإن كنت مسؤولًا فيمكنك أيضًا إيقاف اشتراط كلمة سر المسؤول للإعدادات على مستوى النظام من الخيارات المتقدمة في إعدادات النظام ← الخصوصية والأمان.",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "Dienst konnte nicht deinstalliert werden",
|
||||
"reinstallFailed": "Dienst konnte nicht neu installiert werden",
|
||||
"repairFailed": "Dienst konnte nicht repariert werden",
|
||||
"sidecarFailed": "Fortsetzung mit Sidecar fehlgeschlagen"
|
||||
"sidecarFailed": "Fortsetzung mit Sidecar fehlgeschlagen",
|
||||
"coreAlreadyRunning": "Der Sidecar-Modus konnte nicht gestartet werden: Ein anderer Kern läuft noch (PID {{pid}}). Beenden Sie andere Clash-Verge-Instanzen oder die App, die diesen Kern verwendet, und versuchen Sie es erneut."
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "Systemproxy kann nicht gesetzt werden: macOS hat die Änderung mangels Berechtigung abgelehnt. Installieren Sie den Clash-Verge-Dienst; als Administrator können Sie in den erweiterten Optionen unter Systemeinstellungen → Datenschutz & Sicherheit auch die Passwortpflicht für systemweite Einstellungen deaktivieren.",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "Failed to uninstall the service",
|
||||
"reinstallFailed": "Failed to reinstall the service",
|
||||
"repairFailed": "Failed to repair the service",
|
||||
"sidecarFailed": "Failed to continue with Sidecar"
|
||||
"sidecarFailed": "Failed to continue with Sidecar",
|
||||
"coreAlreadyRunning": "Could not start normal mode (Sidecar): another core is still running (PID {{pid}}). Exit other Clash Verge instances or the app using this core, then retry."
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "Cannot set the system proxy: macOS refused the change for lack of privileges. Install the Clash Verge service; an administrator can also turn off the administrator-password requirement for system-wide settings, in the advanced options of System Settings → Privacy & Security.",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "No se pudo desinstalar el servicio",
|
||||
"reinstallFailed": "No se pudo reinstalar el servicio",
|
||||
"repairFailed": "No se pudo reparar el servicio",
|
||||
"sidecarFailed": "No se pudo continuar con Sidecar"
|
||||
"sidecarFailed": "No se pudo continuar con Sidecar",
|
||||
"coreAlreadyRunning": "No se pudo iniciar el modo Sidecar: hay otro núcleo en ejecución (PID {{pid}}). Cierre las otras instancias de Clash Verge o la aplicación que usa ese núcleo y vuelva a intentarlo."
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "No se puede establecer el proxy del sistema: macOS rechazó el cambio por falta de permisos. Instale el servicio de Clash Verge; si es administrador, también puede desactivar la exigencia de contraseña de administrador para los ajustes de todo el sistema en las opciones avanzadas de Ajustes del Sistema → Privacidad y seguridad.",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "حذف سرویس ناموفق بود",
|
||||
"reinstallFailed": "نصب مجدد سرویس ناموفق بود",
|
||||
"repairFailed": "تعمیر سرویس ناموفق بود",
|
||||
"sidecarFailed": "ادامه با Sidecar ناموفق بود"
|
||||
"sidecarFailed": "ادامه با Sidecar ناموفق بود",
|
||||
"coreAlreadyRunning": "حالت Sidecar شروع نشد: هسته دیگری هنوز در حال اجراست (PID {{pid}}). نمونههای دیگر Clash Verge یا برنامهای که از این هسته استفاده میکند را ببندید و دوباره تلاش کنید."
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "تنظیم پراکسی سیستم ممکن نیست: macOS به دلیل نداشتن دسترسی، این تغییر را رد کرد. سرویس Clash Verge را نصب کنید؛ اگر مدیر هستید میتوانید در گزینههای پیشرفتهٔ تنظیمات سیستم ← حریم خصوصی و امنیت، الزام رمز مدیر برای تنظیمات سراسری سیستم را نیز خاموش کنید.",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "Gagal mencopot layanan",
|
||||
"reinstallFailed": "Gagal memasang ulang layanan",
|
||||
"repairFailed": "Gagal memperbaiki layanan",
|
||||
"sidecarFailed": "Gagal melanjutkan dengan Sidecar"
|
||||
"sidecarFailed": "Gagal melanjutkan dengan Sidecar",
|
||||
"coreAlreadyRunning": "Tidak dapat memulai mode Sidecar: inti lain masih berjalan (PID {{pid}}). Tutup instans Clash Verge lain atau aplikasi yang menggunakan inti tersebut, lalu coba lagi."
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "Tidak dapat mengatur proxy sistem: macOS menolak perubahan ini karena hak akses tidak mencukupi. Pasang layanan Clash Verge; jika Anda administrator, Anda juga dapat mematikan keharusan kata sandi administrator untuk pengaturan seluruh sistem di opsi lanjutan Pengaturan Sistem → Privasi & Keamanan.",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "サービスのアンインストールに失敗しました",
|
||||
"reinstallFailed": "サービスの再インストールに失敗しました",
|
||||
"repairFailed": "サービスの修復に失敗しました",
|
||||
"sidecarFailed": "Sidecar での続行に失敗しました"
|
||||
"sidecarFailed": "Sidecar での続行に失敗しました",
|
||||
"coreAlreadyRunning": "通常モード(Sidecar)を開始できません:別のコアが実行中です(PID {{pid}})。他の Clash Verge またはこのコアを使用しているアプリを終了してから、再試行してください。"
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "システムプロキシを設定できません: 権限が不足しているため macOS が変更を拒否しました。Clash Verge サービスをインストールしてください。管理者であれば、システム設定 → プライバシーとセキュリティの詳細オプションで、システム全体の設定に管理者パスワードを要求する設定をオフにすることもできます。",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "서비스를 제거하지 못했습니다",
|
||||
"reinstallFailed": "서비스를 다시 설치하지 못했습니다",
|
||||
"repairFailed": "서비스를 복구하지 못했습니다",
|
||||
"sidecarFailed": "Sidecar로 계속하지 못했습니다"
|
||||
"sidecarFailed": "Sidecar로 계속하지 못했습니다",
|
||||
"coreAlreadyRunning": "일반 모드(Sidecar)를 시작할 수 없습니다. 다른 코어가 실행 중입니다(PID {{pid}}). 다른 Clash Verge 인스턴스 또는 해당 코어를 사용하는 앱을 종료한 후 다시 시도하세요."
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "시스템 프록시를 설정할 수 없습니다: 권한이 부족하여 macOS가 변경을 거부했습니다. Clash Verge 서비스를 설치하십시오. 관리자라면 시스템 설정 → 개인 정보 보호 및 보안의 고급 옵션에서, 시스템 전체 설정에 관리자 암호를 요구하는 설정을 끌 수도 있습니다.",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "Не удалось удалить службу",
|
||||
"reinstallFailed": "Не удалось переустановить службу",
|
||||
"repairFailed": "Не удалось восстановить службу",
|
||||
"sidecarFailed": "Не удалось продолжить в режиме Sidecar"
|
||||
"sidecarFailed": "Не удалось продолжить в режиме Sidecar",
|
||||
"coreAlreadyRunning": "Не удалось запустить режим Sidecar: другое ядро ещё работает (PID {{pid}}). Закройте другие экземпляры Clash Verge или приложение, использующее это ядро, и повторите попытку."
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "Не удалось настроить системный прокси: macOS отклонил изменение из-за нехватки прав. Установите службу Clash Verge; администратор также может отключить требование пароля администратора для общесистемных настроек в дополнительных параметрах раздела «Системные настройки → Конфиденциальность и безопасность».",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "Hizmet kaldırılamadı",
|
||||
"reinstallFailed": "Hizmet yeniden kurulamadı",
|
||||
"repairFailed": "Hizmet onarılamadı",
|
||||
"sidecarFailed": "Sidecar ile devam edilemedi"
|
||||
"sidecarFailed": "Sidecar ile devam edilemedi",
|
||||
"coreAlreadyRunning": "Sidecar modu başlatılamadı: başka bir çekirdek hâlâ çalışıyor (PID {{pid}}). Diğer Clash Verge örneklerini veya bu çekirdeği kullanan uygulamayı kapatıp yeniden deneyin."
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "Sistem vekil sunucusu ayarlanamıyor: macOS yetki yetersizliği nedeniyle değişikliği reddetti. Clash Verge hizmetini kurun; yöneticiyseniz Sistem Ayarları → Gizlilik ve Güvenlik bölümündeki gelişmiş seçeneklerden, sistem genelindeki ayarlar için yönetici parolası zorunluluğunu da kapatabilirsiniz.",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "Хезмәтне бетереп булмады",
|
||||
"reinstallFailed": "Хезмәтне яңадан урнаштырып булмады",
|
||||
"repairFailed": "Хезмәтне төзәтеп булмады",
|
||||
"sidecarFailed": "Sidecar белән дәвам итеп булмады"
|
||||
"sidecarFailed": "Sidecar белән дәвам итеп булмады",
|
||||
"coreAlreadyRunning": "Sidecar режимын башлап булмады: башка төш әле эшли (PID {{pid}}). Башка Clash Verge нөсхәләрен яки бу төшне кулланучы кушымтаны ябыгыз һәм яңадан сынап карагыз."
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "Система проксиен көйләп булмый: хокуклар җитмәгәнгә күрә macOS бу үзгәрешне кире какты. Clash Verge хезмәтен урнаштырыгыз; әгәр сез администратор булсагыз, Система көйләнмәләре → Шәхсилек вә иминлек бүлегенең өстәмә параметрларында, система дәрәҗәсендәге көйләнмәләр өчен администратор паролен таләп итүне сүндерә аласыз.",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "卸载服务失败",
|
||||
"reinstallFailed": "重新安装服务失败",
|
||||
"repairFailed": "修复服务失败",
|
||||
"sidecarFailed": "继续使用 Sidecar 失败"
|
||||
"sidecarFailed": "继续使用 Sidecar 失败",
|
||||
"coreAlreadyRunning": "无法启动普通模式(Sidecar):已有内核进程正在运行(PID {{pid}})。请先退出其他 Clash Verge 实例或使用该内核的程序,然后重试。"
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "无法设置系统代理:macOS 以权限不足为由拒绝了这次修改。请安装 Clash Verge 服务;管理员也可以在系统设置 → 隐私与安全性的高级选项中,关闭访问系统范围设置需要管理员密码的要求。",
|
||||
|
||||
@@ -663,7 +663,8 @@
|
||||
"uninstallFailed": "解除安裝服務失敗",
|
||||
"reinstallFailed": "重新安裝服務失敗",
|
||||
"repairFailed": "修復服務失敗",
|
||||
"sidecarFailed": "繼續使用 Sidecar 失敗"
|
||||
"sidecarFailed": "繼續使用 Sidecar 失敗",
|
||||
"coreAlreadyRunning": "無法啟動一般模式(Sidecar):已有核心程序正在執行(PID {{pid}})。請先結束其他 Clash Verge 執行個體或使用該核心的程式,然後重試。"
|
||||
},
|
||||
"sysproxy": {
|
||||
"privilegeRequired": "無法設定系統代理:macOS 以權限不足為由拒絕了這次修改。請安裝 Clash Verge 服務;管理者也可以在系統設定 → 隱私權與安全性的進階選項中,關閉存取系統層級設定需要管理者密碼的要求。",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { getPendingFailures, type PendingFailure } from '@/services/cmds'
|
||||
import {
|
||||
getPendingFailures,
|
||||
getSidecarFailure,
|
||||
type PendingFailure,
|
||||
type SidecarFailureSnapshot,
|
||||
} from '@/services/cmds'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { showNotice, syncSidecarFailure } from '@/services/notice-service'
|
||||
|
||||
/** Failures handled by the recovery dialog instead of a toast. */
|
||||
const CODES_SHOWN_AS_A_DIALOG = new Set<string>([
|
||||
@@ -28,13 +33,27 @@ const windowIsWatched = async () => {
|
||||
|
||||
const usePendingFailureReader = (
|
||||
onFailures: (failures: PendingFailure[]) => void,
|
||||
onSidecarFailure?: (snapshot: SidecarFailureSnapshot) => void,
|
||||
) => {
|
||||
// Avoid resubscribing when the handler changes.
|
||||
const handlerRef = useRef(onFailures)
|
||||
handlerRef.current = onFailures
|
||||
const handlerRef = useRef({ onFailures, onSidecarFailure })
|
||||
handlerRef.current = { onFailures, onSidecarFailure }
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
const read = () => {
|
||||
if (handlerRef.current.onSidecarFailure) {
|
||||
void getSidecarFailure()
|
||||
.then((snapshot) => {
|
||||
if (!disposed) handlerRef.current.onSidecarFailure?.(snapshot)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[pending-failures] Sidecar failure could not be read:',
|
||||
error,
|
||||
)
|
||||
})
|
||||
}
|
||||
void (async () => {
|
||||
let failures: PendingFailure[]
|
||||
try {
|
||||
@@ -43,7 +62,7 @@ const usePendingFailureReader = (
|
||||
console.warn('[pending-failures] could not be read:', error)
|
||||
return
|
||||
}
|
||||
handlerRef.current(failures)
|
||||
if (!disposed) handlerRef.current.onFailures(failures)
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -62,6 +81,7 @@ const usePendingFailureReader = (
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unsubscribe()
|
||||
document.removeEventListener('visibilitychange', readWhenVisible)
|
||||
void unlistenFocus.then((unlisten) => unlisten())
|
||||
@@ -96,6 +116,7 @@ export const usePendingFailures = () => {
|
||||
},
|
||||
[showNewFailures],
|
||||
),
|
||||
syncSidecarFailure,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -467,6 +467,15 @@ export const getPendingFailures = async () => {
|
||||
return invoke<PendingFailure[]>('get_pending_failures')
|
||||
}
|
||||
|
||||
export interface SidecarFailureSnapshot {
|
||||
revision: number
|
||||
detail: string | null
|
||||
}
|
||||
|
||||
export const getSidecarFailure = async () => {
|
||||
return invoke<SidecarFailureSnapshot>('get_sidecar_failure')
|
||||
}
|
||||
|
||||
export const getAppUptime = async () => {
|
||||
return invoke<number>('get_app_uptime')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import i18n from 'i18next'
|
||||
import { ReactNode, isValidElement } from 'react'
|
||||
|
||||
import type { FailedOperation } from '@/services/cmds'
|
||||
import type { FailedOperation, SidecarFailureSnapshot } from '@/services/cmds'
|
||||
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
||||
|
||||
type NoticeType = 'success' | 'error' | 'info' | 'warning'
|
||||
@@ -97,6 +97,7 @@ const CODED_ERROR_TRANSLATION_KEYS: Readonly<Record<string, TranslationKey>> = {
|
||||
|
||||
let nextId = 0
|
||||
let notices: NoticeItem[] = []
|
||||
let sidecarFailureRevision = -1
|
||||
const subscribers: Set<NoticeSubscriber> = new Set()
|
||||
|
||||
function notifySubscribers() {
|
||||
@@ -461,7 +462,8 @@ const baseShowNotice = (
|
||||
const code = failureCode(raw) ?? failureCode(message) ?? nestedCode
|
||||
const operation = failureOperation(raw) ?? failureOperation(message)
|
||||
// Suppress duplicate toasts while preserving the caller's id contract.
|
||||
if (isReportedByDialog(code)) return id
|
||||
// Sidecar failures come only from versioned backend state, never delayed command rejections.
|
||||
if (isReportedByDialog(code) || code === 'SERVICE_SIDECAR_FAILED') return id
|
||||
const effectiveDuration = resolveDuration(type, duration)
|
||||
const timerId =
|
||||
effectiveDuration > 0
|
||||
@@ -524,6 +526,29 @@ export function hideNotice(id: number) {
|
||||
notifySubscribers()
|
||||
}
|
||||
|
||||
export function hideNoticesByCode(code: string) {
|
||||
notices
|
||||
.filter((notice) => notice.code === code)
|
||||
.forEach(({ id }) => hideNotice(id))
|
||||
}
|
||||
|
||||
export function syncSidecarFailure(snapshot: SidecarFailureSnapshot) {
|
||||
if (snapshot.revision <= sidecarFailureRevision) return
|
||||
sidecarFailureRevision = snapshot.revision
|
||||
hideNoticesByCode('SERVICE_SIDECAR_FAILED')
|
||||
if (snapshot.detail === null) return
|
||||
|
||||
const failure = { code: 'SERVICE_SIDECAR_FAILED', detail: snapshot.detail }
|
||||
notices = [
|
||||
...notices,
|
||||
buildNotice(nextId++, 'error', 0, {
|
||||
...normalizeNoticeMessage(failure),
|
||||
code: failure.code,
|
||||
}),
|
||||
]
|
||||
notifySubscribers()
|
||||
}
|
||||
|
||||
export function subscribeNotices(subscriber: NoticeSubscriber) {
|
||||
subscribers.add(subscriber)
|
||||
return () => {
|
||||
|
||||
@@ -792,6 +792,7 @@ export const translationKeys = [
|
||||
'settings.feedback.errors.clashService.reinstallFailed',
|
||||
'settings.feedback.errors.clashService.repairFailed',
|
||||
'settings.feedback.errors.clashService.sidecarFailed',
|
||||
'settings.feedback.errors.clashService.coreAlreadyRunning',
|
||||
'settings.feedback.errors.sysproxy.privilegeRequired',
|
||||
'settings.feedback.errors.sysproxy.directFallback',
|
||||
'settings.feedback.errors.sysproxy.guardStopped',
|
||||
|
||||
@@ -820,6 +820,7 @@ export interface TranslationResources {
|
||||
stopFailed: string
|
||||
}
|
||||
clashService: {
|
||||
coreAlreadyRunning: string
|
||||
installFailed: string
|
||||
reinstallFailed: string
|
||||
repairFailed: string
|
||||
|
||||
Reference in New Issue
Block a user