fix(service): adopt IPC 2.7.3 and correct dev fallback handling

This commit is contained in:
wonfen
2026-09-22 17:37:26 +08:00
parent ed0b801573
commit fd06fbb3b7
6 changed files with 145 additions and 33 deletions
Generated
+2 -2
View File
@@ -1223,8 +1223,8 @@ dependencies = [
[[package]]
name = "clash_verge_service_ipc"
version = "2.7.2"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.2#cad270fe753553cfa2fd26fa5c338eabd210a2d5"
version = "2.7.3"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.3#29251d968e1a38fac1de0ebe9d6d69402bea83b4"
dependencies = [
"anyhow",
"kode-bridge",
+58 -26
View File
@@ -19,15 +19,6 @@ export const developmentServiceWatchPaths = [
'Cargo.lock',
].map((name) => join(serviceRepository, name))
function executable(name, platform) {
return join(
serviceRepository,
'target',
'debug',
platform === 'win32' ? `${name}.exe` : name,
)
}
function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, {
@@ -35,10 +26,15 @@ function run(command, args, options = {}) {
stdio: options.stdio ?? 'inherit',
windowsHide: true,
})
let stdout = ''
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk) => {
stdout += chunk
})
child.once('error', reject)
child.once('exit', (code, signal) => {
child.once('close', (code, signal) => {
if (code === 0) {
resolvePromise()
resolvePromise(stdout)
return
}
const error = new Error(
@@ -50,28 +46,64 @@ function run(command, args, options = {}) {
})
}
export async function prepareDevelopmentService({
platform = process.platform,
} = {}) {
export async function prepareDevelopmentService() {
await access(serviceManifest)
await run('cargo', [
'build',
'--manifest-path',
serviceManifest,
'--features',
'standalone,client,development-channel',
'--bins',
])
const output = await run(
'cargo',
[
'build',
'--manifest-path',
serviceManifest,
'--target-dir',
process.env.CARGO_TARGET_DIR ||
join(serviceRepository, 'target', 'development'),
'--features',
'standalone,client,development-channel',
'--bins',
'--message-format=json-render-diagnostics',
],
{ stdio: ['inherit', 'pipe', 'inherit'] },
)
const service = executable('clash-verge-service', platform)
await access(service)
return dirname(service)
const artifacts = new Map()
for (const line of output.split(/\r?\n/).filter(Boolean)) {
const artifact = JSON.parse(line)
if (
artifact.reason === 'compiler-artifact' &&
resolve(artifact.manifest_path) === serviceManifest &&
artifact.target.kind.includes('bin') &&
artifact.executable
) {
artifacts.set(artifact.target.name, resolve(artifact.executable))
}
}
const executables = [
'clash-verge-service',
'clash-verge-service-install',
'clash-verge-service-uninstall',
'service-integration-driver',
].map((name) => {
const executable = artifacts.get(name)
if (!executable)
throw new Error(`Cargo did not report the ${name} executable`)
return executable
})
const serviceDirectory = dirname(executables[0])
for (const executable of executables) {
if (dirname(executable) !== serviceDirectory) {
throw new Error(
'Development service tools were built in different directories',
)
}
await access(executable)
}
return serviceDirectory
}
export async function ensureDevelopmentService({
platform = process.platform,
} = {}) {
const serviceDirectory = await prepareDevelopmentService({ platform })
const serviceDirectory = await prepareDevelopmentService()
const extension = platform === 'win32' ? '.exe' : ''
const installer = join(
serviceDirectory,
+1 -1
View File
@@ -99,7 +99,7 @@ tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.1", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.2", version = "2.7.2", features = [
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.3", version = "2.7.3", features = [
"client",
] }
arc-swap = "1.9.2"
+1 -1
View File
@@ -491,7 +491,7 @@ fn escape_osascript_double_quoted_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(any(target_os = "macos", test))]
#[cfg(target_os = "macos")]
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { beforeEach, expect, it, vi } from 'vitest'
import type { RunState } from '@/services/cmds'
import { useQuery } from '@/services/query-client'
import { ServiceMigrationDialog } from './service-migration-dialog'
vi.mock('react', () => ({ useState: vi.fn() }))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@mui/material', () => ({ Alert: 'div' }))
vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' }))
vi.mock('@/hooks/use-visibility', () => ({ useVisibility: () => true }))
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
vi.mock('@/services/cmds', () => ({ getRuntimeState: vi.fn() }))
vi.mock('@/services/notice-service', () => ({ showNotice: {} }))
vi.mock('@/services/query-client', () => ({
useQuery: vi.fn(),
setCacheData: vi.fn(),
}))
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useState)
.mockReturnValueOnce([false, vi.fn()])
.mockReturnValueOnce([false, vi.fn()])
.mockReturnValueOnce([true, vi.fn()])
})
it.each([
{ service: 'ready', mode: 'NotRunning' },
{ service: 'unknown', mode: 'NotRunning' },
{ service: 'unavailable', mode: 'Service' },
{ service: 'unavailable', mode: 'NotRunning', opInFlight: true },
{ service: 'unavailable', mode: 'NotRunning', pendingAction: 'reinstall' },
] satisfies Partial<RunState>[])(
'does not offer Sidecar continuation from %j',
(state) => {
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
typeof useQuery
>)
const dialog = ServiceMigrationDialog()
expect(dialog.props.open).toBe(true)
expect(dialog.props.disableCancel).toBe(true)
},
)
it.each([
{ service: 'notInstalled', mode: 'NotRunning' },
{ service: 'versionMismatch', mode: 'NotRunning' },
{ service: 'unavailable', mode: 'NotRunning' },
{ service: 'notInstalled', mode: 'Sidecar', pendingAction: 'install' },
] satisfies Partial<RunState>[])(
'offers Sidecar continuation from %j',
(state) => {
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
typeof useQuery
>)
expect(ServiceMigrationDialog().props.disableCancel).toBe(false)
},
)
@@ -44,7 +44,24 @@ export const ServiceMigrationDialog = () => {
? 'install'
: 'reinstall'
const open = loading || workflowIncomplete || needsDecision
const showCheckingMessage = loading || !needsDecision
const checking =
loading ||
!runState ||
runState.opInFlight ||
runState.service === 'unknown'
const showCheckingMessage = checking || !needsDecision
const canContinue = Boolean(
runState &&
!stateRefreshFailed &&
(runState.pendingAction === 'install'
? runState.mode === 'NotRunning' || runState.mode === 'Sidecar'
: !runState.pendingAction &&
!runState.sidecarAllowed &&
runState.mode === 'NotRunning' &&
(runState.service === 'notInstalled' ||
runState.service === 'versionMismatch' ||
runState.service === 'unavailable')),
)
// One cache entry to refresh, so there is nothing left to keep coherent by hand.
const refreshRunState = async () => {
@@ -165,8 +182,8 @@ export const ServiceMigrationDialog = () => {
: 'layout.components.serviceMigration.reinstall',
)}
cancelBtn={t('layout.components.serviceMigration.continueSidecar')}
disableOk={loading}
disableCancel={loading}
disableOk={checking}
disableCancel={checking || !canContinue}
loading={loading}
onOk={() => void handleServiceAction()}
onCancel={() => void handleContinue()}