From 36fa94dddc87089aacf5bbb18a8f15ff4e4429ba Mon Sep 17 00:00:00 2001 From: wangzihao Date: Tue, 1 Sep 2026 11:46:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(ztools-cc-switch):=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=20pluginData=E3=80=81=E6=96=87=E4=BB=B6=E6=8B=96?= =?UTF-8?q?=E6=8B=BD=E4=B8=8E=E9=87=8D=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 适配 ZTools 3.2 的 pluginData、文件拖拽与插件重入生命周期。 ZTools 2.4–3.1 继续沿用旧运行时与交互回退路径。 AI-Co-Authored-By: Codex --- plugins/ztools-cc-switch/CHANGELOG.md | 5 ++ plugins/ztools-cc-switch/README.md | 7 ++ plugins/ztools-cc-switch/package.json | 2 +- plugins/ztools-cc-switch/plugin.json | 2 +- plugins/ztools-cc-switch/preload/index.js | 79 +++++++++++++++++-- .../preload/ztoolsCompatibility.js | 45 +++++++++++ plugins/ztools-cc-switch/src/App.vue | 27 ++++++- plugins/ztools-cc-switch/src/styles.css | 5 ++ .../src/views/SettingsView.vue | 18 ++++- .../tests/hostCompatibilityGate.test.cjs | 54 +++++++++++++ .../tests/preload-gate-child.cjs | 37 +++++++++ .../tests/sidecarClient.test.cjs | 11 +++ 12 files changed, 281 insertions(+), 11 deletions(-) create mode 100644 plugins/ztools-cc-switch/preload/ztoolsCompatibility.js create mode 100644 plugins/ztools-cc-switch/tests/hostCompatibilityGate.test.cjs create mode 100644 plugins/ztools-cc-switch/tests/preload-gate-child.cjs diff --git a/plugins/ztools-cc-switch/CHANGELOG.md b/plugins/ztools-cc-switch/CHANGELOG.md index 97966f904..3a41cda08 100644 --- a/plugins/ztools-cc-switch/CHANGELOG.md +++ b/plugins/ztools-cc-switch/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.54.0] - 2026-09-01 + +- 适配 ZTools 3.2:sidecar 可重建运行时使用插件专属数据目录,备份导出支持能力检测后的文件拖拽。 +- 增加 2.4 版本门禁;3.2 ESC 隐藏后重入不会重复恢复路由、同步或创建临时 UI。 + ## [1.53.0] - 2026-07-28 ### Added diff --git a/plugins/ztools-cc-switch/README.md b/plugins/ztools-cc-switch/README.md index fa600f148..cb3d9613f 100644 --- a/plugins/ztools-cc-switch/README.md +++ b/plugins/ztools-cc-switch/README.md @@ -1,5 +1,12 @@ # AI Provider Switch for ZTools +## ZTools 3.2 与旧版兼容 + +- 3.2 使用 `ztools.getPath('pluginData')` 仅保存可重建的 Rust sidecar 运行时;Provider、备份、用户目录覆盖与安全存储凭据继续使用既有数据目录,降级到 2.4–3.1 不会迁移或丢失密钥。 +- 导出的备份在 3.2 可调用 `startDrag` 拖往外部应用;旧版保持原保存对话框流程。 +- ESC 隐藏只关闭临时弹窗;路由、sidecar 与同步任务保持单例,并在再次进入时用 single-flight 恢复。 +- 低于 ZTools 2.4 仅显示升级提示。 + 版本变更请参阅 [CHANGELOG.md](CHANGELOG.md)。 一个基于 Vue 3、Vite、TailwindCSS 与 Rust sidecar 的 ZTools AI 客户端管理插件。对照 [cc-switch](https://github.com/farion1231/cc-switch) 的公开配置语义,统一管理 Provider、Skills、本地 API 路由、Thinking 整流、用量与请求日志。 diff --git a/plugins/ztools-cc-switch/package.json b/plugins/ztools-cc-switch/package.json index 7fa1e98bd..d6a414abb 100644 --- a/plugins/ztools-cc-switch/package.json +++ b/plugins/ztools-cc-switch/package.json @@ -1,6 +1,6 @@ { "name": "ztools-cc-switch", - "version": "1.53.0", + "version": "1.54.0", "private": true, "license": "MIT", "type": "module", diff --git a/plugins/ztools-cc-switch/plugin.json b/plugins/ztools-cc-switch/plugin.json index fb4225d7b..8337488fc 100644 --- a/plugins/ztools-cc-switch/plugin.json +++ b/plugins/ztools-cc-switch/plugin.json @@ -1,7 +1,7 @@ { "name": "ztools-cc-switch", "title": "AI Provider Switch", - "version": "1.53.0", + "version": "1.54.0", "description": "统一管理 AI 编程客户端 Provider、Skills、本地路由、用量与日志", "author": "ZTools Community", "main": "index.html", diff --git a/plugins/ztools-cc-switch/preload/index.js b/plugins/ztools-cc-switch/preload/index.js index 4dd7056ad..42430d5b2 100644 --- a/plugins/ztools-cc-switch/preload/index.js +++ b/plugins/ztools-cc-switch/preload/index.js @@ -1,5 +1,15 @@ 'use strict' +const { getHostCompatibility } = require('./ztoolsCompatibility') +const hostCompatibility = getHostCompatibility(typeof window === 'undefined' ? undefined : window.ztools) + +// Fail closed before loading manager modules, touching user data, scheduling +// maintenance or restoring routes. Browser-only Vite previews have no ZTools +// bridge and continue to use the existing UI preview path. +if (!hostCompatibility.supported) { + window.ccSwitch = Object.freeze({ getHostCompatibility: () => hostCompatibility }) +} else { + /** * ZTools Preload 能力桥。 * 前端只拿到经过校验的业务方法,不直接暴露 fs/path 等 Node.js 原语。 @@ -43,6 +53,7 @@ const { createLogManager } = require('./logManager') const { createFailoverManager } = require('./failoverManager') const { createClientVisibilityManager } = require('./clientVisibility') const { createRouteLifecycleManager } = require('./routeLifecycleManager') +const { resolveSidecarRuntimeDir } = require('./ztoolsCompatibility') function resolveDataDir() { try { @@ -68,7 +79,11 @@ function getDefaultDataDir() { return path.join(process.env.HOME || process.env.USERPROFILE, '.ztools', 'cc-switch') } const bundledRulesPath = path.join(__dirname, '..', 'default-rules.json') -const sidecar = createSidecarClient({ extractDir: path.join(getDefaultDataDir(), 'runtime', 'sidecar') }) +// Only the ASAR-extracted sidecar is recreated data. Keep all Provider files, +// overrides and encrypted credentials in the established dataDir so a 3.2 -> +// 3.1 downgrade never loses or re-encrypts user data. +const sidecarRuntime = resolveSidecarRuntimeDir(window.ztools, getDefaultDataDir()) +const sidecar = createSidecarClient({ extractDir: sidecarRuntime.path }) let authManager = null function createZtoolsStorage() { const candidate = window.ztools && window.ztools.dbStorage @@ -174,6 +189,35 @@ const toolRuntimeManager = createToolRuntimeManager({ homeDir: configManager.get const providerTerminalManager = createProviderTerminalManager({ homeDir: configManager.getHomeDir() }) const universalProviderManager = createUniversalProviderManager({ dataDir, configManager, storage: ztoolsStorage, secretCodec }) const hostStartupManager = createHostStartupManager({ storage: ztoolsStorage, getRouterStatus: () => routerManager.status(), startRouter: () => routerManager.start() }) +const enterFlights = new Map() +const dragExportPaths = new Map() +const DRAG_EXPORT_TTL_MS = 5 * 60 * 1000 + +function rememberDragExport(filePath) { + const realPath = fs.realpathSync(filePath) + dragExportPaths.clear() + dragExportPaths.set(realPath, Date.now() + DRAG_EXPORT_TTL_MS) +} + +function consumeDragExport(filePath) { + const realPath = fs.realpathSync(filePath) + const expiresAt = dragExportPaths.get(realPath) + if (!expiresAt || expiresAt < Date.now() || !fs.statSync(realPath).isFile()) { + dragExportPaths.delete(realPath) + return null + } + dragExportPaths.delete(realPath) + return realPath +} + +function oncePerEnter(name, task) { + if (enterFlights.has(name)) return enterFlights.get(name) + const flight = Promise.resolve().then(task) + enterFlights.set(name, flight) + const clear = () => { if (enterFlights.get(name) === flight) enterFlights.delete(name) } + flight.then(clear, clear) + return flight +} function findDeepLink(value, depth = 0) { if (depth > 3 || value === null || value === undefined) return null @@ -434,7 +478,11 @@ window.ccSwitch = Object.freeze({ if (Array.isArray(result)) return result[0] || null return result?.canceled ? null : result?.filePaths?.[0] || null }, - exportBackup: (destination, options) => backupManager.exportBackup(destination, options || {}), + exportBackup: async (destination, options) => { + const result = await backupManager.exportBackup(destination, options || {}) + rememberDragExport(result.path) + return result + }, importBackup: (source) => backupManager.importBackup(source), getWebdavConfig: () => webdavSync.getConfig(), saveWebdavConfig: (patch) => webdavSync.saveConfig(patch || {}), @@ -624,8 +672,20 @@ window.ccSwitch = Object.freeze({ getRuntimeInfo: async () => ({ dataDir, homeDir: configManager.getHomeDir(), + sidecarRuntimeDir: sidecarRuntime.path, + sidecarUsesPluginData: sidecarRuntime.usingPluginData, sidecar: await sidecar.getStatus() }), + getHostCompatibility: () => getHostCompatibility(window.ztools), + startDrag: async (filePath) => { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath) || typeof window.ztools?.startDrag !== 'function') return false + try { + const realPath = consumeDragExport(filePath) + if (!realPath) return false + window.ztools.startDrag(realPath) + return true + } catch { return false } + }, getHostStartupSettings: () => hostStartupManager.getSettings(), saveHostStartupSettings: (patch) => hostStartupManager.saveSettings(patch || {}) }) @@ -640,10 +700,18 @@ if (window.ztools && typeof window.ztools.onPluginEnter === 'function') { .catch((error) => window.dispatchEvent(new CustomEvent('cc-switch:deeplink-error', { detail: { message: error.message } }))) } const config = webdavSync.getConfig() - if (config.autoSync && config.hasPassword && config.url) webdavSync.sync().catch(() => {}) + if (config.autoSync && config.hasPassword && config.url) oncePerEnter('webdav-sync', () => webdavSync.sync()).catch(() => {}) const s3Config = s3Sync.getConfig() - if (s3Config.enabled && s3Config.autoSync && s3Config.hasSecretAccessKey) s3Sync.sync().catch(() => {}) - if (hostStartupManager.getSettings().restoreOnPluginEnter) hostStartupManager.restoreRouter().catch((error) => console.warn('[cc-switch] ZTools 进入时恢复路由失败:', error.message)) + if (s3Config.enabled && s3Config.autoSync && s3Config.hasSecretAccessKey) oncePerEnter('s3-sync', () => s3Sync.sync()).catch(() => {}) + if (hostStartupManager.getSettings().restoreOnPluginEnter) oncePerEnter('restore-router', () => hostStartupManager.restoreRouter()).catch((error) => console.warn('[cc-switch] ZTools 进入时恢复路由失败:', error.message)) + }) +} + +if (window.ztools && typeof window.ztools.onPluginOut === 'function') { + window.ztools.onPluginOut(() => { + // 3.2 hides the same renderer. Do not stop the router or recreate the + // sidecar here; the app-level tasks remain single-flight on the next enter. + window.dispatchEvent(new CustomEvent('cc-switch:out')) }) } @@ -663,3 +731,4 @@ setTimeout(() => { }, 3000) console.info('[cc-switch] preload ready') +} diff --git a/plugins/ztools-cc-switch/preload/ztoolsCompatibility.js b/plugins/ztools-cc-switch/preload/ztoolsCompatibility.js new file mode 100644 index 000000000..acccbf357 --- /dev/null +++ b/plugins/ztools-cc-switch/preload/ztoolsCompatibility.js @@ -0,0 +1,45 @@ +'use strict' + +const path = require('node:path') + +const MINIMUM_VERSION = Object.freeze([2, 4, 0]) + +function parseVersion(value) { + if (typeof value !== 'string') return null + const match = value.trim().match(/^[vV]?(\d+)\.(\d+)(?:\.(\d+))?(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/) + if (!match) return null + const parts = [Number(match[1]), Number(match[2]), Number(match[3] || 0)] + return parts.every(Number.isSafeInteger) ? parts : null +} + +function getHostCompatibility(api) { + if (!api) return Object.freeze({ supported: true, detected: false, version: null }) + if (typeof api.getAppVersion !== 'function') { + return Object.freeze({ supported: false, detected: true, version: null }) + } + let version = null + try { version = api.getAppVersion() } catch { + return Object.freeze({ supported: false, detected: true, version: null }) + } + const parsed = parseVersion(version) + if (!parsed) return Object.freeze({ supported: false, detected: true, version: null }) + const difference = parsed.findIndex((part, index) => part !== MINIMUM_VERSION[index]) + const minimumPrerelease = difference === -1 && /^[vV]?\d+\.\d+(?:\.\d+)?-/.test(String(version).trim()) + const supported = !minimumPrerelease && (difference === -1 || parsed[difference] > MINIMUM_VERSION[difference]) + return Object.freeze({ supported, detected: true, version: `${parsed[0]}.${parsed[1]}.${parsed[2]}` }) +} + +function resolveSidecarRuntimeDir(api, legacyDataDir) { + try { + const pluginData = typeof api?.getPath === 'function' ? api.getPath('pluginData') : null + if (typeof pluginData === 'string' && path.isAbsolute(pluginData)) { + return Object.freeze({ path: path.join(pluginData, 'runtime', 'sidecar'), usingPluginData: true }) + } + } catch { + // ZTools 2.4--3.1 reject the 3.2-only path name; stay on the legacy + // runtime cache without changing Provider, credential or override data. + } + return Object.freeze({ path: path.join(legacyDataDir, 'runtime', 'sidecar'), usingPluginData: false }) +} + +module.exports = Object.freeze({ MINIMUM_VERSION, getHostCompatibility, parseVersion, resolveSidecarRuntimeDir }) diff --git a/plugins/ztools-cc-switch/src/App.vue b/plugins/ztools-cc-switch/src/App.vue index 9a58739a9..a2e60ba4d 100644 --- a/plugins/ztools-cc-switch/src/App.vue +++ b/plugins/ztools-cc-switch/src/App.vue @@ -19,6 +19,9 @@ import DeepLinkImportModal from './components/DeepLinkImportModal.vue' import { moveProviderToTarget } from './providerOrder.js' const bridge = window.ccSwitch +const compatibility = ref(bridge?.getHostCompatibility?.() || (window.ztools + ? { supported: false, detected: true, version: null } + : { supported: true, detected: false, version: null })) const themePreference = ref(bridge?.getThemePreference?.() || 'light') const systemThemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)') const clients = ref([ @@ -316,6 +319,7 @@ async function openDesktopLibrary() { } function handlePluginEntry(event) { + if (!compatibility.value.supported) return if (event.detail?.code === 'provider-settings') openSettings('appearance') if (event.detail?.code === 'provider-skills') currentView.value = 'skills' if (event.detail?.code === 'provider-router') { @@ -334,6 +338,15 @@ function handlePluginEntry(event) { if (event.detail?.code === 'provider-agent-config') currentView.value = 'agent-config' } +function handlePluginOut() { + // ZTools 3.2 may hide without unmounting Vue. Close only transient UI; keep + // live routing and preload singleton state for a safe re-entry. + modalOpen.value = false + deepLinkRequest.value = null + draggedProviderId.value = '' + dragTargetProviderId.value = '' +} + function handleDeepLink(event) { deepLinkRequest.value = event.detail } @@ -351,15 +364,21 @@ async function handleDeepLinkImported(result) { } onMounted(async () => { + if (!compatibility.value.supported) { + loading.value = false + return + } window.addEventListener('cc-switch:enter', handlePluginEntry) + window.addEventListener('cc-switch:out', handlePluginOut) window.addEventListener('cc-switch:deeplink', handleDeepLink) window.addEventListener('cc-switch:deeplink-error', handleDeepLinkError) systemThemeQuery?.addEventListener?.('change', handleSystemThemeChange) - await loadData() + if (compatibility.value.supported) await loadData() }) onBeforeUnmount(() => { window.removeEventListener('cc-switch:enter', handlePluginEntry) + window.removeEventListener('cc-switch:out', handlePluginOut) window.removeEventListener('cc-switch:deeplink', handleDeepLink) window.removeEventListener('cc-switch:deeplink-error', handleDeepLinkError) systemThemeQuery?.removeEventListener?.('change', handleSystemThemeChange) @@ -367,7 +386,11 @@ onBeforeUnmount(() => {