diff --git a/Changelog.md b/Changelog.md index 87fca5b5f..7079a8bff 100644 --- a/Changelog.md +++ b/Changelog.md @@ -8,10 +8,14 @@ - 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失 - 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题 - 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题 +- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题 +- 修复内核未运行时,首页仍显示服务模式的问题 +- 修复服务重装重复请求管理员授权、卸载后误报失败的问题 **🖥️ Windows** - 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示 +- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题 **🐧 Linux** @@ -23,7 +27,7 @@ 🚀 优化改进 - 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围 -- 优化系统服务版本检查:版本与当前应用要求不一致时,提示卸载后重新安装 +- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装 - 优化预构建资源,补充 ASN 数据库文件 - 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃 diff --git a/scripts/dev-service.mjs b/scripts/dev-service.mjs index b5c093509..4f91840a8 100644 --- a/scripts/dev-service.mjs +++ b/scripts/dev-service.mjs @@ -1,27 +1,23 @@ -import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -import { access, mkdir, readFile, writeFile } from 'node:fs/promises' +import { execFileSync, spawn } from 'node:child_process' +import { access } 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 repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const serviceRepository = resolve( repositoryRoot, '..', 'clash-verge-service-ipc', ) const serviceManifest = join(serviceRepository, 'Cargo.toml') -const developmentReceipt = join( - repositoryRoot, - 'target', - 'development-service.json', -) +export const developmentServiceWatchPaths = [ + 'src', + 'resources', + 'Cargo.toml', + 'Cargo.lock', +].map((name) => join(serviceRepository, name)) function executable(name, platform) { return join( @@ -54,78 +50,6 @@ function run(command, args, options = {}) { }) } -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', - ) -} - -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, } = {}) { @@ -148,40 +72,28 @@ 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 extension = platform === 'win32' ? '.exe' : '' const installer = join( serviceDirectory, - platform === 'win32' - ? 'clash-verge-service-install.exe' - : 'clash-verge-service-install', + `clash-verge-service-install${extension}`, ) - 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. - } + const host = execFileSync('rustc', ['-vV'], { encoding: 'utf8' }).match( + /^host: (.+)$/m, + )?.[1] + if (!host) throw new Error('rustc did not report its host target') + const args = ['--prepare-install', '--ensure'] + for (const name of ['verge-mihomo', 'verge-mihomo-alpha']) { + args.push( + '--core', + `${name}${extension}`, + join( + repositoryRoot, + 'src-tauri', + 'sidecar', + `${name}-${host}${extension}`, + ), + ) } - - try { - await elevateInstaller(installer, platform) - } catch (error) { - if (error.exitCode !== 75) throw error - } - await run(driver, ['ready']) - await writeInstalledFingerprint(fingerprint) + await run(installer, args) return serviceDirectory } diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 53350b109..7235a73d6 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -10,6 +10,7 @@ import { } from './dev-control.mjs' import { developmentServiceDirectoryEnvironment, + developmentServiceWatchPaths, ensureDevelopmentService, prepareDevelopmentService, } from './dev-service.mjs' @@ -31,6 +32,9 @@ export function buildTauriInvocation( env.RUSTFLAGS = '--cfg tokio_unstable' args.push('tokio-trace') } + for (const path of developmentServiceWatchPaths) { + args.push('--additional-watch-folders', path) + } return { command: 'pnpm', args, diff --git a/scripts/prebuild.mjs b/scripts/prebuild.mjs index 264cb31af..22dd96032 100644 --- a/scripts/prebuild.mjs +++ b/scripts/prebuild.mjs @@ -586,6 +586,41 @@ async function resolveServiceBundle() { path.join(cwd, 'src-tauri', 'Cargo.toml'), 'utf8', ) + const serviceDependency = cargoManifest + .split(/\r?\n/) + .find((line) => line.trimStart().startsWith('clash_verge_service_ipc =')) + const sourcePath = serviceDependency?.match(/\bpath\s*=\s*"([^"]+)"/)?.[1] + if (sourcePath) { + const manifest = path.resolve(cwd, 'src-tauri', sourcePath, 'Cargo.toml') + const targetDirectory = path.join(cwd, 'target', 'bundled-service') + execFileSync( + 'cargo', + [ + 'build', + '--manifest-path', + manifest, + '--target-dir', + targetDirectory, + '--target', + SIDECAR_HOST, + '--release', + '--features', + 'standalone,client', + '--bins', + ], + { stdio: 'inherit' }, + ) + await fsp.mkdir(SERVICE_DIR, { recursive: true }) + for (const { sourceFile, targetPath } of files) { + await fsp.copyFile( + path.join(targetDirectory, SIDECAR_HOST, 'release', sourceFile), + targetPath, + ) + if (platform !== 'win32') await fsp.chmod(targetPath, 0o755) + await updateHashCache(targetPath) + } + return + } const { archiveFile, downloadURL } = resolveServiceRelease( cargoManifest, SIDECAR_HOST, diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index a9716f604..554f1be0b 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -185,16 +185,17 @@ where apply_proxy().await } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", test))] async fn run_service_start_with_sidecar_fallback( start_service: Start, confirm_idle: Check, start_sidecar: Fallback, + record_unavailable: impl FnOnce(std::string::String), ) -> Result<()> where Start: FnOnce() -> StartFuture, StartFuture: std::future::Future>, - Check: FnOnce(bool) -> CheckFuture, + Check: FnOnce() -> CheckFuture, CheckFuture: std::future::Future>, Fallback: FnOnce(anyhow::Error) -> FallbackFuture, FallbackFuture: std::future::Future>, @@ -209,9 +210,11 @@ where if refusal.is_some() && !location_refused { return Err(error); } - // A lost response is not a refusal: require a stopped service and no leftover core. - if let Err(probe_error) = confirm_idle(location_refused).await { - return Err(error.context(format!("Sidecar fallback was not safe: {probe_error:#}"))); + // A lost start response can hide a running core; the shared check must resolve occupancy. + if let Err(probe_error) = confirm_idle().await { + let error = error.context(format!("Sidecar fallback was not safe: {probe_error:#}")); + record_unavailable(format!("{error:#}")); + return Err(error); } logging!( warn, @@ -224,18 +227,18 @@ where .map_err(|error| error.context(format!("Sidecar fallback failed after Service failure: {reason}"))) } -async fn run_sidecar_termination_transition( +async fn run_sidecar_termination_transition( clear_proxy: Clear, terminate_sidecar: Terminate, ) -> Result<()> where Clear: FnOnce() -> ClearFuture, ClearFuture: std::future::Future>, - Terminate: FnOnce(), + Terminate: FnOnce() -> TerminateFuture, + TerminateFuture: std::future::Future>, { clear_proxy().await?; - terminate_sidecar(); - Ok(()) + terminate_sidecar().await } async fn run_service_config_replacement_transition< @@ -374,6 +377,10 @@ impl CoreManager { 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?; + } 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. @@ -596,8 +603,12 @@ impl CoreManager { { run_service_start_with_sidecar_fallback( || self.start_core_by_service(), - crate::core::service::windows_fallback::confirm_idle, + clash_verge_service_ipc::execution::check_sidecar_available, |error| self.start_sidecar_after_service_failure(error), + |reason| { + crate::core::runstate::RUN_STATE + .observe(crate::core::runstate::ServiceHealth::Unavailable(reason)); + }, ) .await } @@ -635,7 +646,7 @@ impl CoreManager { ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(), _ => return Ok(()), }; - if let Err(error) = crate::core::service::windows_fallback::confirm_idle(false).await { + if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await { logging!( warn, Type::Core, @@ -701,10 +712,7 @@ impl CoreManager { CLASH_LOGGER.clear_logs(); match *self.get_running_mode() { RunningMode::Service => self.stop_core_by_service().await, - RunningMode::Sidecar => { - self.stop_core_by_sidecar_unprepared(); - Ok(()) - } + RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await, RunningMode::NotRunning => Ok(()), } } @@ -1205,9 +1213,10 @@ mod tests { let result = run_sidecar_termination_transition( || transition_step(&calls, "handoff-proxy-clear", fail_at), - || { + || async { sidecar_alive.store(false, Ordering::Release); calls.lock().push("handoff-sidecar-stop"); + Ok(()) }, ) .await; @@ -1487,6 +1496,46 @@ mod tests { } } + #[tokio::test] + async fn blocked_windows_fallback_records_failure_without_starting_sidecar() { + use crate::core::runstate::{FakeEnv, RunStateStore, ServiceHealth}; + + for idle in [false, true] { + let store = RunStateStore::new(FakeEnv::new()); + store.observe(ServiceHealth::Ready); + let started = AtomicBool::new(false); + let result = super::run_service_start_with_sidecar_fallback( + || async { + Err(crate::core::service::ServiceStartRefusal { + code: clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16, + core_path: "verge-mihomo.exe".into(), + message: "no administrator-approved copy is installed".into(), + } + .into()) + }, + || async move { + anyhow::ensure!(idle, "another core is running"); + Ok(()) + }, + |_| async { + started.store(true, Ordering::SeqCst); + Ok(()) + }, + |reason| store.observe(ServiceHealth::Unavailable(reason)), + ) + .await; + + assert_eq!(started.load(Ordering::SeqCst), idle); + assert_eq!(result.is_ok(), idle); + assert!(!store.state().sidecar_allowed); + if !idle { + assert!(store.state().service_needs_attention()); + assert!(!store.state().service_usable()); + assert!(result.is_err_and(|error| format!("{error:#}").contains("another core is running"))); + } + } + } + #[test] fn failed_service_install_can_continue_with_an_existing_sidecar() { assert!(can_allow_sidecar_for_session( diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index 79fc511fe..db808db77 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -87,6 +87,7 @@ pub struct CoreManager { job_handle: ArcSwapOption, config_update_in_progress: AtomicBool, core_readiness_state: AtomicU64, + sidecar_exit: tokio::sync::Mutex>>, // 串行化 start/stop/restart 和 sidecar→service 交接。 // 锁序固定为 config_update_in_progress → lifecycle_lock。 pub(crate) lifecycle_lock: tokio::sync::Mutex<()>, @@ -114,6 +115,7 @@ impl Default for CoreManager { job_handle: ArcSwapOption::new(None), config_update_in_progress: AtomicBool::new(false), core_readiness_state: AtomicU64::new(0), + sidecar_exit: tokio::sync::Mutex::new(None), lifecycle_lock: tokio::sync::Mutex::new(()), #[cfg(target_os = "windows")] handoff_watcher_running: AtomicBool::new(false), diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index a7b1b3a2c..a10f6bac8 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -11,7 +11,6 @@ use crate::{ use anyhow::{Context as _, Result}; use clash_verge_logging::Type; use log::Level; -use scopeguard::defer; use std::path::Path; use tauri_plugin_mihomo::MihomoExt as _; use tauri_plugin_shell::ShellExt as _; @@ -153,6 +152,8 @@ impl CoreManager { #[tracing::instrument(skip_all, level = "info", fields(pid = tracing::field::Empty))] pub(super) async fn start_core_by_sidecar(&self) -> Result<()> { + self.wait_for_sidecar_exit().await?; + let execution = clash_verge_service_ipc::execution::reserve_sidecar().await?; self.core_stopped(); let sidecar_ipc = dirs::sidecar_ipc_path()?; @@ -205,6 +206,8 @@ impl CoreManager { config_dir.display() ) })?; + let (terminated, termination) = tokio::sync::oneshot::channel(); + *self.sidecar_exit.lock().await = Some(execution.release_after_exit(child.pid(), termination)); #[cfg(target_os = "windows")] let job = { match create_and_assign_sidecar_job(child.pid()) { @@ -248,6 +251,7 @@ impl CoreManager { CLASH_LOGGER.append_log(message); } tauri_plugin_shell::process::CommandEvent::Terminated(term) => { + let _ = terminated.send(()); let manager = Self::global(); let _ = manager.invalidate_core_readiness_if(core_readiness_generation); let message = if let Some(code) = term.code { @@ -298,10 +302,7 @@ impl CoreManager { /// Terminates the sidecar after its caller has successfully cleared the /// system proxy. - pub(super) fn stop_core_by_sidecar_unprepared(&self) { - defer! { - self.core_stopped(); - } + pub(super) async fn stop_core_by_sidecar_unprepared(&self) -> Result<()> { if let Some(child) = self.take_child_sidecar() { let pid = child.pid(); @@ -317,6 +318,22 @@ impl CoreManager { logging!(warn, Type::Core, "failed to terminate sidecar PID {pid}: {error:#}"); } } + let result = self.wait_for_sidecar_exit().await; + self.core_stopped(); + result + } + + async fn wait_for_sidecar_exit(&self) -> Result<()> { + let mut exit = self.sidecar_exit.lock().await; + if let Some(task) = exit.as_mut() { + tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .context("Sidecar has not exited; core handoff is blocked")? + .context("Sidecar exit monitoring failed")?; + exit.take(); + } + drop(exit); + Ok(()) } pub(super) async fn start_core_by_service(&self) -> Result<()> { diff --git a/src-tauri/src/core/runstate/env.rs b/src-tauri/src/core/runstate/env.rs index debc642de..0fd60202d 100644 --- a/src-tauri/src/core/runstate/env.rs +++ b/src-tauri/src/core/runstate/env.rs @@ -28,7 +28,33 @@ pub struct RealEnv; impl RunStateEnv for RealEnv { async fn probe_service_version(&self) -> Result { let response = clash_verge_service_ipc::get_version().await?; + let core = if response.code == 0 + && response.data.as_ref().is_some_and(|info| { + info.supports_client( + clash_verge_service_ipc::ProtocolVersion::current(), + clash_verge_service_ipc::MIN_REQUIRED_SERVICE_REVISION, + ) + }) { + let name = format!( + "{}{}", + crate::config::Config::verge().await.latest_arc().get_valid_clash_core(), + std::env::consts::EXE_SUFFIX + ); + let status = clash_verge_service_ipc::inspect_installation(&[clash_verge_service_ipc::CoreRequirement { + name: name.clone(), + sha256: None, + }]) + .await?; + status + .cores + .into_iter() + .find(|core| core.name == name) + .map(|core| core.availability) + } else { + None + }; Ok(ServiceVersionReply { + core, code: response.code, message: response.message, protocol: response.data, @@ -119,6 +145,7 @@ mod fake { #[must_use] pub fn service_ready(self) -> Self { self.with_evidence(true).always_replying(Ok(ServiceVersionReply { + core: Some(clash_verge_service_ipc::CoreAvailability::Ready), code: 0, message: "ok".to_owned(), protocol: Some(ProtocolInfo::current()), @@ -128,6 +155,7 @@ mod fake { #[must_use] pub fn service_version_mismatch(self) -> Self { self.with_evidence(true).always_replying(Ok(ServiceVersionReply { + core: Some(clash_verge_service_ipc::CoreAvailability::Ready), code: 0, message: "ok".to_owned(), protocol: None, diff --git a/src-tauri/src/core/runstate/mod.rs b/src-tauri/src/core/runstate/mod.rs index 3153b4666..e06979ed5 100644 --- a/src-tauri/src/core/runstate/mod.rs +++ b/src-tauri/src/core/runstate/mod.rs @@ -152,6 +152,10 @@ impl RunStateStore { self.observe(ServiceHealth::Ready); Ok(self.state()) } + ServiceVersionCheck::CoreUnavailable(error) => { + self.observe(ServiceHealth::Unavailable(error.clone())); + bail!(error) + } ServiceVersionCheck::NeedsReinstall(error) => { self.observe(ServiceHealth::VersionMismatch); bail!(error) @@ -178,7 +182,10 @@ impl RunStateStore { } match self.env.probe_service_version().await { - Ok(reply) => classify_service_health(probe_outcome(&reply), has_marker, ""), + Ok(reply) => match classify_service_version_reply(&reply) { + ServiceVersionCheck::CoreUnavailable(reason) => ServiceHealth::Unavailable(reason), + _ => classify_service_health(probe_outcome(&reply), has_marker, ""), + }, Err(error) => { logging!(warn, Type::Service, "current service IPC is unavailable: {error:#}"); classify_service_health( @@ -487,6 +494,7 @@ mod tests { fn ready_reply() -> ServiceVersionReply { ServiceVersionReply { + core: Some(clash_verge_service_ipc::CoreAvailability::Ready), code: 0, message: "ok".to_owned(), protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()), diff --git a/src-tauri/src/core/runstate/probe.rs b/src-tauri/src/core/runstate/probe.rs index a4989b2ff..f5857e027 100644 --- a/src-tauri/src/core/runstate/probe.rs +++ b/src-tauri/src/core/runstate/probe.rs @@ -16,6 +16,7 @@ pub struct ServiceVersionReply { pub code: u16, pub message: String, pub protocol: Option, + pub core: Option, } /// The verdict on a [`ServiceVersionReply`]. @@ -23,6 +24,7 @@ pub struct ServiceVersionReply { pub enum ServiceVersionCheck { Ready, NeedsReinstall(String), + CoreUnavailable(String), } /// What a live probe of the currently-installed Service told us. @@ -42,7 +44,17 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer info.build_version == SERVICE_VERSION && info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION) }) { - return ServiceVersionCheck::Ready; + return match &reply.core { + Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready, + Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => { + ServiceVersionCheck::CoreUnavailable(format!( + "approved core was rejected: {reason}; reinstall the service to repair it" + )) + } + _ => ServiceVersionCheck::CoreUnavailable( + "the selected approved core is missing or does not match; reinstall the service to repair it".into(), + ), + }; } let detail = if reply.code == 0 { @@ -71,10 +83,10 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer /// Classify a version reply into the probe outcome used by [`classify_service_health`]. pub fn probe_outcome(reply: &ServiceVersionReply) -> CurrentServiceProbe { - if classify_service_version_reply(reply) == ServiceVersionCheck::Ready { - CurrentServiceProbe::Ready - } else { - CurrentServiceProbe::VersionMismatch + match classify_service_version_reply(reply) { + ServiceVersionCheck::Ready => CurrentServiceProbe::Ready, + ServiceVersionCheck::NeedsReinstall(_) => CurrentServiceProbe::VersionMismatch, + ServiceVersionCheck::CoreUnavailable(_) => CurrentServiceProbe::Unavailable, } } @@ -104,6 +116,7 @@ mod tests { fn ready_reply() -> ServiceVersionReply { ServiceVersionReply { + core: Some(clash_verge_service_ipc::CoreAvailability::Ready), code: 0, message: "ok".to_owned(), protocol: Some(ProtocolInfo::current()), @@ -119,9 +132,26 @@ mod tests { assert_eq!(probe_outcome(&ready_reply()), CurrentServiceProbe::Ready); } + #[test] + fn compatible_service_with_missing_core_is_unavailable() { + for core in [ + None, + Some(clash_verge_service_ipc::CoreAvailability::Missing), + Some(clash_verge_service_ipc::CoreAvailability::DigestMismatch), + ] { + let reply = ServiceVersionReply { core, ..ready_reply() }; + assert!(matches!( + classify_service_version_reply(&reply), + ServiceVersionCheck::CoreUnavailable(_) + )); + assert_eq!(probe_outcome(&reply), CurrentServiceProbe::Unavailable); + } + } + #[test] fn a_reply_without_protocol_information_needs_reinstall() { let reply = ServiceVersionReply { + core: Some(clash_verge_service_ipc::CoreAvailability::Ready), code: 0, message: "ok".to_owned(), protocol: None, @@ -139,6 +169,7 @@ mod tests { #[test] fn a_nonzero_code_needs_reinstall() { let reply = ServiceVersionReply { + core: Some(clash_verge_service_ipc::CoreAvailability::Ready), code: 7, message: "nope".to_owned(), protocol: Some(ProtocolInfo::current()), @@ -154,6 +185,7 @@ mod tests { let mut info = ProtocolInfo::current(); info.protocol.epoch = info.protocol.epoch.wrapping_add(1); let reply = ServiceVersionReply { + core: Some(clash_verge_service_ipc::CoreAvailability::Ready), code: 0, message: "ok".to_owned(), protocol: Some(info), @@ -174,6 +206,7 @@ mod tests { let mut info = ProtocolInfo::current(); info.protocol.revision = revision; let reply = ServiceVersionReply { + core: Some(clash_verge_service_ipc::CoreAvailability::Ready), code: 0, message: "ok".to_owned(), protocol: Some(info), @@ -192,6 +225,7 @@ mod tests { for protocol in [None, Some(old_epoch)] { for code in [0, 1] { let reply = ServiceVersionReply { + core: None, code, message: "unsupported command".to_owned(), protocol: protocol.clone(), diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 1ef636089..3aee2bb4f 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -27,7 +27,6 @@ use clash_verge_service_ipc::{ use once_cell::sync::Lazy; use parking_lot::Mutex; use std::{ - borrow::Cow, collections::HashMap, env::current_exe, future::Future, @@ -37,9 +36,6 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; -#[cfg(target_os = "windows")] -pub(crate) mod windows_fallback; - static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0); static ACTIVE_SERVICE_SESSION: Lazy>> = Lazy::new(|| Mutex::new(None)); static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false); @@ -490,28 +486,6 @@ fn service_core_path(clash_core: &str, bin_ext: &str) -> Result { Ok(sibling) } -/// 卸载服务前以 root 清理残留 core 和 IPC 套接字。 -#[cfg(target_os = "macos")] -fn macos_force_stop_core_shell() -> String { - use crate::config::IVerge; - - // 只清理 root 拥有的服务内核。 - let mut parts: Vec = IVerge::VALID_CLASH_CORES - .iter() - .map(|core| format!("/usr/bin/pkill -U root -x {core} 2>/dev/null || true")) - .collect(); - - if let Ok(ipc) = dirs::ipc_path() - && let Ok(ipc_str) = dirs::path_to_str(&ipc) - { - // 转义单引号,避免破坏 shell 参数。 - let escaped = ipc_str.replace('\'', r"'\''"); - parts.push(format!("/bin/rm -f '{escaped}' 2>/dev/null || true")); - } - - parts.join("; ") -} - #[cfg(target_os = "macos")] fn escape_osascript_double_quoted_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") @@ -522,12 +496,6 @@ fn shell_single_quote(value: &str) -> String { format!("'{}'", value.replace('\'', r"'\''")) } -#[cfg(any(target_os = "macos", test))] -fn macos_install_shell(install_path: &Path, gid: u32) -> String { - let install_quoted = shell_single_quote(&install_path.to_string_lossy()); - format!("cd /; CLASH_VERGE_SERVICE_GID={gid} {install_quoted}") -} - fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result) -> Result { #[cfg(feature = "verge-dev")] { @@ -581,88 +549,6 @@ fn uninstall_service() -> Result<()> { Ok(()) } -#[cfg(target_os = "windows")] -fn install_service() -> Result<()> { - logging!(info, Type::Service, "install service"); - let cores = crate::config::IVerge::VALID_CLASH_CORES - .iter() - .map(|core| service_core_path(core, ".exe")) - .collect::>>()?; - install_service_with_cores(&cores, run_windows_service_installer) -} - -#[cfg(any(target_os = "windows", target_os = "linux", test))] -fn install_service_with_cores( - cores: &[PathBuf], - mut run_installer: impl FnMut(&[std::ffi::OsString]) -> Result<()>, -) -> Result<()> { - let cores: Vec<_> = cores.iter().filter(|core| core.is_file()).collect(); - if cores.is_empty() { - bail!("no core executable is available; restore a bundled core before installing the service"); - } - let mut arguments = vec!["--install-service".into()]; - for core in cores { - let digest = sha256_hex(core)?; - arguments.extend([ - "--install-core".into(), - core.as_os_str().to_owned(), - "--sha256".into(), - digest.into(), - ]); - } - run_installer(&arguments).context("failed to install the service and its cores; choose Repair to retry") -} - -#[cfg(target_os = "windows")] -fn run_windows_service_installer(arguments: &[std::ffi::OsString]) -> Result<()> { - use std::process::Output; - - use deelevate::{PrivilegeLevel, Token}; - use runas::Command as RunasCommand; - use std::os::windows::process::CommandExt as _; - - let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || { - Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe")) - })?; - - if !install_path.exists() { - bail!(format!("installer not found: {install_path:?}")); - } - - let token = Token::with_current_process()?; - let level = token.privilege_level()?; - let output = match level { - PrivilegeLevel::NotPrivileged => { - let status = RunasCommand::new(&install_path).args(arguments).show(false).status()?; - Output { - status, - stdout: Vec::new(), - stderr: Vec::new(), - } - } - _ => { - // StdCommand returns Output directly - StdCommand::new(&install_path) - .args(arguments) - .creation_flags(0x08000000) - .output()? - } - }; - - if let Some((code, err)) = check_output_error(&output) { - logging!( - error, - Type::Service, - "failed to install service code: {}, details: {}", - code, - err - ); - bail!("failed to install service code: {}, details: {}", code, err); - } - - Ok(()) -} - #[cfg(target_os = "linux")] fn uninstall_service() -> Result<()> { logging!(info, Type::Service, "uninstall service"); @@ -714,64 +600,6 @@ fn uninstall_service() -> Result<()> { Ok(()) } -#[cfg(target_os = "linux")] -fn install_service() -> Result<()> { - logging!(info, Type::Service, "install service"); - let cores = crate::config::IVerge::VALID_CLASH_CORES - .iter() - .map(|core| service_core_path(core, "")) - .collect::>>()?; - install_service_with_cores(&cores, run_linux_service_installer) -} - -#[cfg(target_os = "linux")] -fn run_linux_service_installer(arguments: &[std::ffi::OsString]) -> Result<()> { - let install_path = packaged_service_tool_path("clash-verge-service-install", || { - Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install")) - })?; - - if !install_path.exists() { - bail!(format!("installer not found: {install_path:?}")); - } - - let elevator = crate::utils::help::linux_elevator(); - let output = if linux_running_as_root() { - StdCommand::new(&install_path).args(arguments).output()? - } else { - let mut elevated = StdCommand::new(&elevator); - if elevator.contains("pkexec") { - elevated.arg("--disable-internal-agent"); - } - let result = elevated.arg(&install_path).args(arguments).output()?; - - // 如果 pkexec 执行失败,回退到 sudo - if !result.status.success() && elevator.contains("pkexec") { - logging!( - warn, - Type::Service, - "pkexec failed with code {}, falling back to sudo", - result.status.code().unwrap_or(-1) - ); - StdCommand::new("sudo").arg(&install_path).args(arguments).output()? - } else { - result - } - }; - - if let Some((code, err)) = check_output_error(&output) { - logging!( - error, - Type::Service, - "failed to install service code: {}, details: {}", - code, - err - ); - bail!("failed to install service code: {}, details: {}", code, err); - } - - Ok(()) -} - #[cfg(target_os = "linux")] fn linux_running_as_root() -> bool { use crate::core::handle; @@ -798,9 +626,8 @@ fn uninstall_service() -> Result<()> { // clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref()); let prompt = clash_verge_i18n::t!("service.adminUninstallPrompt"); - // 先清理服务残留,再执行卸载器。 let uninstall_quoted = shell_single_quote(&uninstall_shell); - let shell = format!("cd /; {}; {uninstall_quoted}", macos_force_stop_core_shell()); + let shell = format!("cd /; {uninstall_quoted}"); let shell = escape_osascript_double_quoted_string(&shell); let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#); @@ -818,64 +645,46 @@ fn uninstall_service() -> Result<()> { Ok(()) } -#[cfg(target_os = "macos")] fn install_service() -> Result<()> { - logging!(info, Type::Service, "install service"); - - let binary_path = packaged_service_tool_path("clash-verge-service", dirs::service_path)?; - let install_path = packaged_service_tool_path("clash-verge-service-install", || { - Ok(dirs::service_path()?.with_file_name("clash-verge-service-install")) - })?; - - if !install_path.exists() { - bail!(format!("installer not found: {install_path:?}")); - } - - macos_service_tool_path(&binary_path)?; - let install_path = macos_service_tool_path(&install_path)?; - - // clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref()); - - let gid = tauri_plugin_clash_verge_sysinfo::current_gid(); - let prompt = clash_verge_i18n::t!("service.adminInstallPrompt"); - let shell = macos_install_shell(&install_path, gid); - let shell = escape_osascript_double_quoted_string(&shell); - let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#); - - let output = StdCommand::new("osascript").args(vec!["-e", &command]).output()?; - if let Some((code, err)) = check_output_error(&output) { - logging!( - error, - Type::Service, - "failed to install service code: {}, details: {}", - code, - err - ); - bail!("failed to install service code: {}, details: {}", code, err); - } - - Ok(()) + let executable = current_exe()?; + let cores = crate::config::IVerge::VALID_CLASH_CORES + .iter() + .map(|core| { + let name = format!("{core}{}", std::env::consts::EXE_SUFFIX); + clash_verge_service_ipc::management::CoreSource { + path: executable.with_file_name(&name), + name, + } + }) + .collect::>(); + invoke_service_install(&cores, false) } -fn check_output_error(output: &std::process::Output) -> Option<(i32, Cow<'_, str>)> { - if output.status.success() { - return None; - } - let code = output.status.code().unwrap_or(-1); - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.is_empty() { - return Some((code, stderr)); - } - let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.is_empty() { - return Some((code, stdout)); - } - Some((code, Cow::Borrowed("Unknown error"))) +fn invoke_service_install(cores: &[clash_verge_service_ipc::management::CoreSource], core_only: bool) -> Result<()> { + let name = format!("clash-verge-service-install{}", std::env::consts::EXE_SUFFIX); + let installer = packaged_service_tool_path(&name, || { + #[cfg(target_os = "linux")] + let executable = tauri::utils::platform::current_exe()?; + #[cfg(not(target_os = "linux"))] + let executable = dirs::service_path()?; + Ok(executable.with_file_name(&name)) + })?; + #[cfg(unix)] + let gid = Some(tauri_plugin_clash_verge_sysinfo::current_gid()); + #[cfg(windows)] + let gid = None; + clash_verge_service_ipc::management::install( + &installer, + cores, + core_only, + gid, + &clash_verge_i18n::t!("service.adminInstallPrompt"), + ) } fn reinstall_service() -> Result<()> { logging!(info, Type::Service, "reinstall service"); - uninstall_service()?; + // The installer replaces an existing registration and its cores in one elevation. install_service() } @@ -897,158 +706,20 @@ fn force_reinstall_service() -> Result<()> { /// hash was computed is refused by the installer instead of published. pub fn stage_approved_core(core_path: &Path) -> Result<()> { tokio::task::block_in_place(|| { - let digest = sha256_hex(core_path)?; - run_core_install(core_path, &digest) + invoke_service_install( + &[clash_verge_service_ipc::management::CoreSource { + name: core_path + .file_name() + .context("core has no filename")? + .to_string_lossy() + .into_owned(), + path: core_path.to_path_buf(), + }], + true, + ) }) } -fn sha256_hex(path: &Path) -> Result { - use sha2::{Digest as _, Sha256}; - use std::io::Read as _; - - let mut file = std::fs::File::open(path).with_context(|| format!("failed to open {path:?} for hashing"))?; - let mut hasher = Sha256::new(); - let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice(); - loop { - let read = file - .read(&mut buffer) - .with_context(|| format!("failed to read {path:?}"))?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - } - Ok(hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect()) -} - -#[cfg(target_os = "windows")] -fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> { - use deelevate::{PrivilegeLevel, Token}; - use runas::Command as RunasCommand; - use std::os::windows::process::CommandExt as _; - use std::process::Output; - - let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || { - Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe")) - })?; - if !install_path.exists() { - bail!(format!("installer not found: {install_path:?}")); - } - - let token = Token::with_current_process()?; - let output = match token.privilege_level()? { - PrivilegeLevel::NotPrivileged => { - let status = RunasCommand::new(&install_path) - .arg("--install-core") - .arg(core_path) - .arg("--sha256") - .arg(sha256_hex) - .show(false) - .status()?; - Output { - status, - stdout: Vec::new(), - stderr: Vec::new(), - } - } - _ => StdCommand::new(&install_path) - .creation_flags(0x08000000) - .arg("--install-core") - .arg(core_path) - .arg("--sha256") - .arg(sha256_hex) - .output()?, - }; - - if let Some((code, err)) = check_output_error(&output) { - bail!("failed to stage the core for the service, code {code}: {err}"); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> { - let install_path = packaged_service_tool_path("clash-verge-service-install", || { - Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install")) - })?; - if !install_path.exists() { - bail!(format!("installer not found: {install_path:?}")); - } - - let core_argument = core_path.as_os_str(); - let output = if linux_running_as_root() { - StdCommand::new(&install_path) - .arg("--install-core") - .arg(core_argument) - .arg("--sha256") - .arg(sha256_hex) - .output()? - } else { - let elevator = crate::utils::help::linux_elevator(); - let mut elevated = StdCommand::new(&elevator); - // pkexec-only option; other elevators such as sudo reject unknown flags outright. - if elevator.contains("pkexec") { - elevated.arg("--disable-internal-agent"); - } - let result = elevated - .arg(&install_path) - .arg("--install-core") - .arg(core_argument) - .arg("--sha256") - .arg(sha256_hex) - .output()?; - if !result.status.success() && elevator.contains("pkexec") { - logging!( - warn, - Type::Service, - "pkexec failed with code {}, falling back to sudo", - result.status.code().unwrap_or(-1) - ); - StdCommand::new("sudo") - .arg(&install_path) - .arg("--install-core") - .arg(core_argument) - .arg("--sha256") - .arg(sha256_hex) - .output()? - } else { - result - } - }; - - if let Some((code, err)) = check_output_error(&output) { - bail!("failed to stage the core for the service, code {code}: {err}"); - } - Ok(()) -} - -#[cfg(target_os = "macos")] -fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> { - let install_path = packaged_service_tool_path("clash-verge-service-install", || { - Ok(dirs::service_path()?.with_file_name("clash-verge-service-install")) - })?; - if !install_path.exists() { - bail!(format!("installer not found: {install_path:?}")); - } - let install_path = macos_service_tool_path(&install_path)?; - - let prompt = clash_verge_i18n::t!("service.adminInstallPrompt"); - let shell = format!( - "cd /; {} --install-core {} --sha256 {}", - shell_single_quote(&install_path.to_string_lossy()), - shell_single_quote(&core_path.to_string_lossy()), - shell_single_quote(sha256_hex), - ); - let shell = escape_osascript_double_quoted_string(&shell); - let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#); - - let output = StdCommand::new("osascript").args(["-e", &command]).output()?; - if let Some((code, err)) = check_output_error(&output) { - bail!("failed to stage the core for the service, code {code}: {err}"); - } - Ok(()) -} - /// Dispatches a privileged platform operation on a blocking thread. pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()> { let (operation, label): (fn() -> Result<()>, &'static str) = match action { @@ -1124,6 +795,16 @@ impl std::fmt::Display for ServiceStartRefusal { impl std::error::Error for ServiceStartRefusal {} +fn record_service_start_refusal( + store: &RunStateStore, + refusal: ServiceStartRefusal, +) -> anyhow::Error { + if store.state().mode == crate::core::manager::RunningMode::NotRunning { + store.observe(ServiceHealth::Unavailable(refusal.to_string())); + } + refusal.into() +} + /// 尝试使用服务启动core #[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))] pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> { @@ -1164,12 +845,14 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<() Handle::notice_message("service_core::repair_required", ""); } start_owner_monitor(); - return Err(ServiceStartRefusal { - code: response.code, - core_path: request.runtime.core_path, - message: err_msg, - } - .into()); + return Err(record_service_start_refusal( + &RUN_STATE, + ServiceStartRefusal { + code: response.code, + core_path: request.runtime.core_path, + message: err_msg, + }, + )); } let result = response.data.context("Clash Verge Service 未返回会话信息")?; @@ -2208,8 +1891,8 @@ pub static SERVICE_MANAGER: ServiceManager = ServiceManager; mod tests { use super::{ ServiceHealth, ServiceStatus, capture_generation_before, claim_owner_recovery_generation, - generate_service_session_token, macos_install_shell, mark_service_unavailable_after_owner_loss, - owner_recovery_policy, service_core_path_for, session_matches_status, + generate_service_session_token, mark_service_unavailable_after_owner_loss, owner_recovery_policy, + service_core_path_for, session_matches_status, }; #[cfg(unix)] use super::{service_core_path_for_with_publisher, service_tool_path_for}; @@ -2263,85 +1946,6 @@ mod tests { } } - #[test] - fn service_install_combines_service_and_attested_cores() -> anyhow::Result<()> { - use std::ffi::OsString; - let root = TestDirectory::new("service install")?; - let cores: Vec<_> = crate::config::IVerge::VALID_CLASH_CORES - .iter() - .map(|name| root.path().join(format!("{name}{}", std::env::consts::EXE_SUFFIX))) - .collect(); - for core in &cores { - std::fs::write(core, b"abc")?; - } - let mut calls = Vec::new(); - super::install_service_with_cores(&cores, |arguments| { - calls.push(arguments.to_vec()); - Ok(()) - })?; - assert_eq!(calls.len(), 1, "service and cores must share one elevation"); - let digest = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; - let mut expected: Vec = vec!["--install-service".into()]; - expected.extend(cores.iter().flat_map(|core| { - [ - "--install-core".into(), - core.as_os_str().to_owned(), - "--sha256".into(), - digest.into(), - ] - })); - assert_eq!(calls[0], expected); - Ok(()) - } - - #[test] - fn service_install_skips_unavailable_cores() -> anyhow::Result<()> { - let root = TestDirectory::new("missing-core")?; - let core = root - .path() - .join(format!("verge-mihomo{}", std::env::consts::EXE_SUFFIX)); - let missing = root - .path() - .join(format!("verge-mihomo-alpha{}", std::env::consts::EXE_SUFFIX)); - std::fs::write(&core, b"abc")?; - let cores = [core.clone(), missing.clone(), root.path().to_path_buf()]; - let mut calls = Vec::new(); - super::install_service_with_cores(&cores, |arguments| { - calls.push(arguments.to_vec()); - Ok(()) - })?; - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].len(), 5); - assert_eq!(calls[0][0], "--install-service"); - assert_eq!(calls[0][2], core.as_os_str()); - - let error = super::install_service_with_cores(&[missing, root.path().to_path_buf()], |_| { - panic!("no available core must fail before invoking the installer") - }) - .expect_err("installation requires at least one available core"); - assert!(error.to_string().contains("no core executable")); - Ok(()) - } - - #[test] - fn service_install_propagates_installer_failure() -> anyhow::Result<()> { - let root = TestDirectory::new("install-failure")?; - let core = root - .path() - .join(format!("verge-mihomo{}", std::env::consts::EXE_SUFFIX)); - std::fs::write(&core, b"abc")?; - let mut calls = 0; - let result = super::install_service_with_cores(std::slice::from_ref(&core), |_| { - calls += 1; - bail!("installer failed with exit code 1") - }); - let error = result.expect_err("installation failure must not be reported as success"); - assert!(format!("{error:#}").contains("exit code 1")); - assert!(error.to_string().contains("Repair")); - assert_eq!(calls, 1); - Ok(()) - } - fn staging_directory(home: &Path) -> PathBuf { home.join("Applications/.clash-verge-rev-dev/service-core") } @@ -2425,17 +2029,6 @@ mod tests { Ok(()) } - #[test] - fn macos_install_shell_starts_from_root_without_nested_sudo() { - let shell = macos_install_shell(Path::new("/safe/service-tools/clash-verge-service-install"), 20); - - assert_eq!( - shell, - "cd /; CLASH_VERGE_SERVICE_GID=20 '/safe/service-tools/clash-verge-service-install'" - ); - assert!(!shell.contains("sudo")); - } - #[cfg(unix)] #[test] fn development_service_core_refresh_atomically_replaces_bytes() -> anyhow::Result<()> { @@ -2594,6 +2187,38 @@ mod tests { assert_eq!(store.generation_count(), generation + 1); } + #[tokio::test] + async fn refused_core_start_can_continue_with_sidecar() -> anyhow::Result<()> { + use clash_verge_service_ipc::ServiceErrorCode; + for code in [ + ServiceErrorCode::InvalidInstallLocation, + ServiceErrorCode::InvalidRuntimeAsset, + ServiceErrorCode::OwnerSwitchFailed, + ] { + let store = fake_store(); + store.observe(ServiceHealth::Ready); + let error = super::record_service_start_refusal( + &store, + super::ServiceStartRefusal { + code: code as u16, + core_path: "/development/service-core/verge-mihomo".into(), + message: "no administrator-approved copy is installed".into(), + }, + ); + + assert!(error.downcast_ref::().is_some()); + let state = store.settled().await; + assert_eq!(state.mode, crate::core::manager::RunningMode::NotRunning); + assert!(state.service_needs_attention()); + assert!(!state.service_usable()); + assert!(matches!(status_of(&store), ServiceStatus::Unavailable(_))); + store.allow_sidecar_for_session()?; + assert_eq!(status_of(&store), ServiceStatus::SidecarAllowed); + assert!(store.state().tun_should_be_disabled(true)); + } + Ok(()) + } + #[test] fn cached_readiness_is_false_while_a_service_operation_is_running() { let store = fake_store(); diff --git a/src-tauri/src/core/service/windows_fallback.rs b/src-tauri/src/core/service/windows_fallback.rs deleted file mode 100644 index 0212ee103..000000000 --- a/src-tauri/src/core/service/windows_fallback.rs +++ /dev/null @@ -1,109 +0,0 @@ -use anyhow::{Context as _, Result, bail}; -use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}; -use windows_service::{ - Error as WindowsServiceError, - service::{ServiceAccess, ServiceState}, - service_manager::{ServiceManager, ServiceManagerAccess}, -}; -use windows_sys::Win32::{ - Foundation::{ERROR_NO_MORE_FILES, ERROR_SERVICE_DOES_NOT_EXIST, INVALID_HANDLE_VALUE}, - System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, - }, -}; - -pub(crate) async fn confirm_idle(location_refused: bool) -> Result<()> { - if location_refused { - let credentials = crate::core::owner_identity::current_owner_credentials()?; - let response = clash_verge_service_ipc::get_status(&credentials).await?; - if response.code != 0 { - bail!( - "cannot confirm idle service: code {} ({})", - response.code, - response.message - ); - } - let status = response.data.context("service did not return its status")?; - if status.is_active - || status.core_pid.is_some() - || status.service_state != clash_verge_service_ipc::ServiceLifecycleState::Running - { - bail!("service still has an active or recovering core session"); - } - } - tokio::task::spawn_blocking(move || { - if !location_refused { - require_stopped_service()?; - } - require_no_core_process(!location_refused)?; - if !location_refused { - require_stopped_service()?; - } - Ok(()) - }) - .await - .context("service fallback probe did not finish")? -} - -fn require_stopped_service() -> Result<()> { - let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?; - match manager.open_service( - clash_verge_service_ipc::WINDOWS_SERVICE_NAME, - ServiceAccess::QUERY_STATUS, - ) { - Ok(service) => { - let status = service.query_status()?; - if status.current_state != ServiceState::Stopped || status.process_id.is_some_and(|pid| pid != 0) { - bail!( - "service is {:?}; Sidecar fallback requires a stopped service", - status.current_state - ); - } - Ok(()) - } - Err(WindowsServiceError::Winapi(error)) - if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST as i32) => - { - Ok(()) - } - Err(error) => Err(error).context("cannot confirm that the service has stopped"), - } -} - -fn require_no_core_process(include_service: bool) -> Result<()> { - let raw = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; - if raw == INVALID_HANDLE_VALUE { - return Err(std::io::Error::last_os_error()).context("cannot inspect existing core processes"); - } - let snapshot = unsafe { OwnedHandle::from_raw_handle(raw) }; - let mut entry = PROCESSENTRY32W { - dwSize: std::mem::size_of::() as u32, - ..unsafe { std::mem::zeroed() } - }; - let mut found = unsafe { Process32FirstW(snapshot.as_raw_handle(), &mut entry) }; - while found != 0 { - let length = entry - .szExeFile - .iter() - .position(|value| *value == 0) - .unwrap_or(entry.szExeFile.len()); - let name = String::from_utf16_lossy(&entry.szExeFile[..length]); - // SCM can be stopped while a core from an earlier service process remains alive. - if crate::config::IVerge::VALID_CLASH_CORES - .iter() - .any(|core| name.eq_ignore_ascii_case(&format!("{core}.exe"))) - || (include_service && name.eq_ignore_ascii_case("clash-verge-service.exe")) - { - bail!( - "process {name} (PID {}) is still running; refusing a second core", - entry.th32ProcessID - ); - } - found = unsafe { Process32NextW(snapshot.as_raw_handle(), &mut entry) }; - } - let error = std::io::Error::last_os_error(); - if error.raw_os_error() != Some(ERROR_NO_MORE_FILES as i32) { - return Err(error).context("core process enumeration did not complete"); - } - Ok(()) -} diff --git a/src/components/home/system-info-card.tsx b/src/components/home/system-info-card.tsx index 1faeef603..d71aa039a 100644 --- a/src/components/home/system-info-card.tsx +++ b/src/components/home/system-info-card.tsx @@ -4,6 +4,7 @@ import { AdminPanelSettingsOutlined, DnsOutlined, ExtensionOutlined, + PauseCircleOutlined, } from '@mui/icons-material' import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material' import { useLockFn } from 'ahooks' @@ -29,7 +30,8 @@ export const SystemInfoCard = () => { const { t } = useTranslation() const { verge, patchVerge } = useVerge() const navigate = useNavigate() - const { isAdminMode, isSidecarMode, mutateSystemState } = useSystemState() + const { runningMode, isAdminMode, isSidecarMode, mutateSystemState } = + useSystemState() const { installServiceAndRestartCore } = useServiceInstaller() const { checkUpdate: triggerCheckUpdate, lastCheckUpdate } = useUpdate(true) @@ -135,6 +137,14 @@ export const SystemInfoCard = () => { ) const getModeIcon = () => { + if (runningMode === 'NotRunning') { + return ( + + ) + } if (isAdminMode) { if (!isSidecarMode) { return ( @@ -174,6 +184,9 @@ export const SystemInfoCard = () => { } const getModeText = () => { + if (runningMode === 'NotRunning') { + return t('home.components.systemInfo.badges.notRunning') + } if (isAdminMode) { if (!isSidecarMode) { return t('home.components.systemInfo.badges.adminServiceMode') diff --git a/src/locales/ar/home.json b/src/locales/ar/home.json index 9048e69d6..4bf03590f 100644 --- a/src/locales/ar/home.json +++ b/src/locales/ar/home.json @@ -63,6 +63,7 @@ "settings": "الإعدادات" }, "badges": { + "notRunning": "النواة لا تعمل", "adminMode": "Administrator Mode", "serviceMode": "وضع الخدمة", "sidecarMode": "User Mode", diff --git a/src/locales/de/home.json b/src/locales/de/home.json index ad0476ac9..5e1aa776d 100644 --- a/src/locales/de/home.json +++ b/src/locales/de/home.json @@ -63,6 +63,7 @@ "settings": "Einstellungen" }, "badges": { + "notRunning": "Kern läuft nicht", "adminMode": "Administrator-Modus", "serviceMode": "Service-Modus", "sidecarMode": "Benutzermodus", diff --git a/src/locales/en/home.json b/src/locales/en/home.json index 7aa2eab33..e4ec3b6a7 100644 --- a/src/locales/en/home.json +++ b/src/locales/en/home.json @@ -63,6 +63,7 @@ "settings": "Settings" }, "badges": { + "notRunning": "Core Not Running", "adminMode": "Administrator Mode", "serviceMode": "Service Mode", "sidecarMode": "User Mode", diff --git a/src/locales/es/home.json b/src/locales/es/home.json index c0660e749..5c3611623 100644 --- a/src/locales/es/home.json +++ b/src/locales/es/home.json @@ -63,6 +63,7 @@ "settings": "Ajustes" }, "badges": { + "notRunning": "Núcleo detenido", "adminMode": "Modo de administrador", "serviceMode": "Modo de servicio", "sidecarMode": "Modo de usuario", diff --git a/src/locales/fa/home.json b/src/locales/fa/home.json index 388bf4d44..468522ab9 100644 --- a/src/locales/fa/home.json +++ b/src/locales/fa/home.json @@ -63,6 +63,7 @@ "settings": "تنظیمات" }, "badges": { + "notRunning": "هسته در حال اجرا نیست", "adminMode": "Administrator Mode", "serviceMode": "حالت سرویس", "sidecarMode": "User Mode", diff --git a/src/locales/id/home.json b/src/locales/id/home.json index aa9459475..5bfb7dc60 100644 --- a/src/locales/id/home.json +++ b/src/locales/id/home.json @@ -63,6 +63,7 @@ "settings": "Pengaturan" }, "badges": { + "notRunning": "Inti tidak berjalan", "adminMode": "Administrator Mode", "serviceMode": "Mode Layanan", "sidecarMode": "User Mode", diff --git a/src/locales/jp/home.json b/src/locales/jp/home.json index 0cf3ff964..96f8b426e 100644 --- a/src/locales/jp/home.json +++ b/src/locales/jp/home.json @@ -63,6 +63,7 @@ "settings": "設定" }, "badges": { + "notRunning": "コア停止中", "adminMode": "管理者モード", "serviceMode": "サービスモード", "sidecarMode": "ユーザーモード", diff --git a/src/locales/ko/home.json b/src/locales/ko/home.json index b921e99ba..d81852344 100644 --- a/src/locales/ko/home.json +++ b/src/locales/ko/home.json @@ -63,6 +63,7 @@ "settings": "설정" }, "badges": { + "notRunning": "코어 실행 중이 아님", "adminMode": "관리자 모드", "serviceMode": "서비스 모드", "sidecarMode": "사용자 모드", diff --git a/src/locales/ru/home.json b/src/locales/ru/home.json index a77e603e8..d46e3d227 100644 --- a/src/locales/ru/home.json +++ b/src/locales/ru/home.json @@ -63,6 +63,7 @@ "settings": "Настройки" }, "badges": { + "notRunning": "Ядро не запущено", "adminMode": "Режим администратора", "serviceMode": "Режим службы", "sidecarMode": "Пользовательский режим", diff --git a/src/locales/tr/home.json b/src/locales/tr/home.json index 6908e057d..845b2e961 100644 --- a/src/locales/tr/home.json +++ b/src/locales/tr/home.json @@ -63,6 +63,7 @@ "settings": "Ayarlar" }, "badges": { + "notRunning": "Çekirdek çalışmıyor", "adminMode": "Yönetici Modu", "serviceMode": "Hizmet Modu", "sidecarMode": "Kullanıcı Modu", diff --git a/src/locales/tt/home.json b/src/locales/tt/home.json index 6939efc57..b04be2af3 100644 --- a/src/locales/tt/home.json +++ b/src/locales/tt/home.json @@ -63,6 +63,7 @@ "settings": "Көйләүләр" }, "badges": { + "notRunning": "Төш эшләми", "adminMode": "Administrator Mode", "serviceMode": "Сервис режимы", "sidecarMode": "User Mode", diff --git a/src/locales/zh/home.json b/src/locales/zh/home.json index d9421744e..dd73294a0 100644 --- a/src/locales/zh/home.json +++ b/src/locales/zh/home.json @@ -63,6 +63,7 @@ "settings": "设置" }, "badges": { + "notRunning": "内核未运行", "adminMode": "管理员模式", "serviceMode": "服务模式", "sidecarMode": "用户模式", diff --git a/src/locales/zhtw/home.json b/src/locales/zhtw/home.json index 86029aa88..3c7c4be92 100644 --- a/src/locales/zhtw/home.json +++ b/src/locales/zhtw/home.json @@ -63,6 +63,7 @@ "settings": "設定" }, "badges": { + "notRunning": "核心未執行", "adminMode": "管理員模式", "serviceMode": "服務模式", "sidecarMode": "使用者模式", diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 972a2b33d..d8ec074d2 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -59,6 +59,7 @@ export const translationKeys = [ 'home.components.systemInfo.fields.lastCheckUpdate', 'home.components.systemInfo.fields.vergeVersion', 'home.components.systemInfo.actions.settings', + 'home.components.systemInfo.badges.notRunning', 'home.components.systemInfo.badges.adminMode', 'home.components.systemInfo.badges.serviceMode', 'home.components.systemInfo.badges.sidecarMode', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index 076b98c6e..7f2d5e1d1 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -127,6 +127,7 @@ export interface TranslationResources { badges: { adminMode: string adminServiceMode: string + notRunning: string serviceMode: string sidecarMode: string }