mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
fix(service): unify installation and safe sidecar handoff
Delegate service and core installation to Service IPC so replacement uses one elevation. Share fallback occupancy checks and retain the execution guard until the Sidecar exits. Track missing approved cores and start refusals as unavailable, and show when no core is running. Build matching service tools from the local IPC dependency and pass both core executables through the Dev installer.
This commit is contained in:
+5
-1
@@ -8,10 +8,14 @@
|
||||
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
|
||||
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
|
||||
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
|
||||
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
|
||||
- 修复内核未运行时,首页仍显示服务模式的问题
|
||||
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
|
||||
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
@@ -23,7 +27,7 @@
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
|
||||
- 优化系统服务版本检查:版本与当前应用要求不一致时,提示卸载后重新安装
|
||||
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
|
||||
- 优化预构建资源,补充 ASN 数据库文件
|
||||
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
|
||||
|
||||
|
||||
+27
-115
@@ -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 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}`,
|
||||
),
|
||||
)
|
||||
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)
|
||||
await run(installer, args)
|
||||
return serviceDirectory
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, StartFuture, Check, CheckFuture, Fallback, FallbackFuture>(
|
||||
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<Output = Result<()>>,
|
||||
Check: FnOnce(bool) -> CheckFuture,
|
||||
Check: FnOnce() -> CheckFuture,
|
||||
CheckFuture: std::future::Future<Output = Result<()>>,
|
||||
Fallback: FnOnce(anyhow::Error) -> FallbackFuture,
|
||||
FallbackFuture: std::future::Future<Output = Result<()>>,
|
||||
@@ -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<Clear, ClearFuture, Terminate>(
|
||||
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate, TerminateFuture>(
|
||||
clear_proxy: Clear,
|
||||
terminate_sidecar: Terminate,
|
||||
) -> Result<()>
|
||||
where
|
||||
Clear: FnOnce() -> ClearFuture,
|
||||
ClearFuture: std::future::Future<Output = Result<()>>,
|
||||
Terminate: FnOnce(),
|
||||
Terminate: FnOnce() -> TerminateFuture,
|
||||
TerminateFuture: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
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(
|
||||
|
||||
@@ -87,6 +87,7 @@ pub struct CoreManager {
|
||||
job_handle: ArcSwapOption<OwnedHandle>,
|
||||
config_update_in_progress: AtomicBool,
|
||||
core_readiness_state: AtomicU64,
|
||||
sidecar_exit: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
// 串行化 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),
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -28,7 +28,33 @@ pub struct RealEnv;
|
||||
impl RunStateEnv for RealEnv {
|
||||
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
|
||||
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,
|
||||
|
||||
@@ -152,6 +152,10 @@ impl<E: RunStateEnv> RunStateStore<E> {
|
||||
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<E: RunStateEnv> RunStateStore<E> {
|
||||
}
|
||||
|
||||
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()),
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct ServiceVersionReply {
|
||||
pub code: u16,
|
||||
pub message: String,
|
||||
pub protocol: Option<ProtocolInfo>,
|
||||
pub core: Option<clash_verge_service_ipc::CoreAvailability>,
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
|
||||
+93
-468
@@ -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<Mutex<Option<ActiveServiceSession>>> = 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<PathBuf> {
|
||||
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<String> = 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<PathBuf>) -> Result<PathBuf> {
|
||||
#[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::<Result<Vec<_>>>()?;
|
||||
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::<Result<Vec<_>>>()?;
|
||||
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:?}"));
|
||||
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,
|
||||
}
|
||||
|
||||
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(())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
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<String> {
|
||||
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<E: RunStateEnv>(
|
||||
store: &RunStateStore<E>,
|
||||
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 {
|
||||
return Err(record_service_start_refusal(
|
||||
&RUN_STATE,
|
||||
ServiceStartRefusal {
|
||||
code: response.code,
|
||||
core_path: request.runtime.core_path,
|
||||
message: err_msg,
|
||||
}
|
||||
.into());
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
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<OsString> = 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::<super::ServiceStartRefusal>().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();
|
||||
|
||||
@@ -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::<PROCESSENTRY32W>() 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(())
|
||||
}
|
||||
@@ -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 (
|
||||
<PauseCircleOutlined
|
||||
sx={{ color: 'text.secondary', fontSize: 16 }}
|
||||
titleAccess={t('home.components.systemInfo.badges.notRunning')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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')
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "الإعدادات"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "النواة لا تعمل",
|
||||
"adminMode": "Administrator Mode",
|
||||
"serviceMode": "وضع الخدمة",
|
||||
"sidecarMode": "User Mode",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Kern läuft nicht",
|
||||
"adminMode": "Administrator-Modus",
|
||||
"serviceMode": "Service-Modus",
|
||||
"sidecarMode": "Benutzermodus",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Settings"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Core Not Running",
|
||||
"adminMode": "Administrator Mode",
|
||||
"serviceMode": "Service Mode",
|
||||
"sidecarMode": "User Mode",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Ajustes"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Núcleo detenido",
|
||||
"adminMode": "Modo de administrador",
|
||||
"serviceMode": "Modo de servicio",
|
||||
"sidecarMode": "Modo de usuario",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "تنظیمات"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "هسته در حال اجرا نیست",
|
||||
"adminMode": "Administrator Mode",
|
||||
"serviceMode": "حالت سرویس",
|
||||
"sidecarMode": "User Mode",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Pengaturan"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Inti tidak berjalan",
|
||||
"adminMode": "Administrator Mode",
|
||||
"serviceMode": "Mode Layanan",
|
||||
"sidecarMode": "User Mode",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "設定"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "コア停止中",
|
||||
"adminMode": "管理者モード",
|
||||
"serviceMode": "サービスモード",
|
||||
"sidecarMode": "ユーザーモード",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "설정"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "코어 실행 중이 아님",
|
||||
"adminMode": "관리자 모드",
|
||||
"serviceMode": "서비스 모드",
|
||||
"sidecarMode": "사용자 모드",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Настройки"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Ядро не запущено",
|
||||
"adminMode": "Режим администратора",
|
||||
"serviceMode": "Режим службы",
|
||||
"sidecarMode": "Пользовательский режим",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Ayarlar"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Çekirdek çalışmıyor",
|
||||
"adminMode": "Yönetici Modu",
|
||||
"serviceMode": "Hizmet Modu",
|
||||
"sidecarMode": "Kullanıcı Modu",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Көйләүләр"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Төш эшләми",
|
||||
"adminMode": "Administrator Mode",
|
||||
"serviceMode": "Сервис режимы",
|
||||
"sidecarMode": "User Mode",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "设置"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "内核未运行",
|
||||
"adminMode": "管理员模式",
|
||||
"serviceMode": "服务模式",
|
||||
"sidecarMode": "用户模式",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "設定"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "核心未執行",
|
||||
"adminMode": "管理員模式",
|
||||
"serviceMode": "服務模式",
|
||||
"sidecarMode": "使用者模式",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -127,6 +127,7 @@ export interface TranslationResources {
|
||||
badges: {
|
||||
adminMode: string
|
||||
adminServiceMode: string
|
||||
notRunning: string
|
||||
serviceMode: string
|
||||
sidecarMode: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user