From fd06fbb3b71ee5c4467bc0e3cc505dcac5348371 Mon Sep 17 00:00:00 2001 From: wonfen Date: Tue, 22 Sep 2026 17:37:26 +0800 Subject: [PATCH] fix(service): adopt IPC 2.7.3 and correct dev fallback handling --- Cargo.lock | 4 +- scripts/dev-service.mjs | 84 +++++++++++++------ src-tauri/Cargo.toml | 2 +- src-tauri/src/core/service.rs | 2 +- .../layout/service-migration-dialog.test.ts | 63 ++++++++++++++ .../layout/service-migration-dialog.tsx | 23 ++++- 6 files changed, 145 insertions(+), 33 deletions(-) create mode 100644 src/components/layout/service-migration-dialog.test.ts diff --git a/Cargo.lock b/Cargo.lock index c18416c52..3cf59e78c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/scripts/dev-service.mjs b/scripts/dev-service.mjs index 4f91840a8..63ab5ea38 100644 --- a/scripts/dev-service.mjs +++ b/scripts/dev-service.mjs @@ -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, diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4d3fc6fd3..24371828d 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 3aee2bb4f..ae057082e 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -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"'\''")) } diff --git a/src/components/layout/service-migration-dialog.test.ts b/src/components/layout/service-migration-dialog.test.ts new file mode 100644 index 000000000..592c5e0ec --- /dev/null +++ b/src/components/layout/service-migration-dialog.test.ts @@ -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[])( + '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[])( + 'offers Sidecar continuation from %j', + (state) => { + vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType< + typeof useQuery + >) + expect(ServiceMigrationDialog().props.disableCancel).toBe(false) + }, +) diff --git a/src/components/layout/service-migration-dialog.tsx b/src/components/layout/service-migration-dialog.tsx index b5ca565da..4e0963c2b 100644 --- a/src/components/layout/service-migration-dialog.tsx +++ b/src/components/layout/service-migration-dialog.tsx @@ -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()}