diff --git a/plugins/ztools-cc-switch/CHANGELOG.md b/plugins/ztools-cc-switch/CHANGELOG.md index 97966f904..1a27e8c7e 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:默认 Provider、备份、日志、凭据和 sidecar runtime 首次启动时校验迁移到插件专属数据目录并删除旧目录;用户主动配置的外部数据目录保持不动。备份导出支持能力检测后的文件拖拽。 +- 增加 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..ddf9f29b0 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 首次启动会把默认 Provider、备份、日志、凭据及 runtime 校验迁移到 `ztools.getPath('pluginData')` 并删除旧目录;用户主动配置的外部数据目录保持不动。2.4–3.1 直接安装仍使用原 `userData` 目录,完成迁移后不保证降级的数据可见性。 +- 导出的备份在 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 整流、用量与请求日志。 @@ -224,7 +231,7 @@ ztools publish - 前端只调用 `window.ccSwitch` 的业务方法,不直接获得 `fs`、`path` 等 Node 原语。 - Web UI 仅借用 ZTools 生命周期、目录选择、Shell 打开与隔离存储能力;路径实化、工具命令白名单、Provider 环境变量和凭据注入均在 Preload 内完成。 -- Provider 数据默认保存在 ZTools `userData/ztools-cc-switch/providers.json`,也可覆盖插件数据目录;文件权限按当前用户写入。 +- Provider 数据在 ZTools 3.2 默认保存在插件专属 `pluginData/providers.json`,2.4–3.1 使用 `userData/ztools-cc-switch/providers.json`;也可覆盖插件数据目录,文件权限按当前用户写入。 - OAuth Token 不写入 Provider JSON 或 WebDAV 备份;前端只能读取脱敏账号元数据,无法调用内部 Token 获取方法。 - WebDAV 密码与 S3 Secret Access Key 使用系统 `safeStorage` 加密,设置页只显示“已保存”状态。 - WebDAV 与自定义 S3 Endpoint 的远程地址必须使用 HTTPS;仅 `localhost`、`127.0.0.1` 与 `::1` 允许 HTTP。 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/configManager.js b/plugins/ztools-cc-switch/preload/configManager.js index 629dcc764..9ffe253b5 100644 --- a/plugins/ztools-cc-switch/preload/configManager.js +++ b/plugins/ztools-cc-switch/preload/configManager.js @@ -6,7 +6,7 @@ * 设计原则: * 1. 只修改各客户端明确支持的配置字段,保留用户的其他设置。 * 2. 所有写入都先生成 `.bak`,再通过同目录临时文件原子替换。 - * 3. Provider 数据独立保存在 ZTools userData,不把密钥写进前端存储。 + * 3. Provider 数据保存在插件专属数据目录,不把密钥写进前端存储。 */ const fs = require('node:fs') diff --git a/plugins/ztools-cc-switch/preload/index.js b/plugins/ztools-cc-switch/preload/index.js index 4dd7056ad..61a0fcb6a 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,8 @@ const { createLogManager } = require('./logManager') const { createFailoverManager } = require('./failoverManager') const { createClientVisibilityManager } = require('./clientVisibility') const { createRouteLifecycleManager } = require('./routeLifecycleManager') +const { resolveSidecarRuntimeDir } = require('./ztoolsCompatibility') +const { migrateDefaultDataDir } = require('./pluginDataMigration') function resolveDataDir() { try { @@ -52,7 +64,8 @@ function resolveDataDir() { if (fs.statSync(resolved).isDirectory()) return resolved } if (window.ztools && typeof window.ztools.getPath === 'function') { - return path.join(window.ztools.getPath('userData'), 'ztools-cc-switch') + const legacyDataDir = path.join(window.ztools.getPath('userData'), 'ztools-cc-switch') + return migrateDefaultDataDir(window.ztools, legacyDataDir).path } } catch (error) { console.warn('[cc-switch] 无法读取 ZTools userData,使用 Home 目录降级:', error.message) @@ -68,7 +81,10 @@ 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') }) +// The default plugin-owned data directory migrates to pluginData on 3.2. A +// user-selected external override remains untouched and outside uninstall cleanup. +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 +190,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 +479,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 +673,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 +701,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 +732,4 @@ setTimeout(() => { }, 3000) console.info('[cc-switch] preload ready') +} diff --git a/plugins/ztools-cc-switch/preload/pluginDataMigration.js b/plugins/ztools-cc-switch/preload/pluginDataMigration.js new file mode 100644 index 000000000..1f048ece4 --- /dev/null +++ b/plugins/ztools-cc-switch/preload/pluginDataMigration.js @@ -0,0 +1,92 @@ +'use strict' + +const crypto = require('node:crypto') +const fs = require('node:fs') +const path = require('node:path') + +function fileDigest(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex') +} + +function assertSafeTree(root) { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const entryPath = path.join(root, entry.name) + const stat = fs.lstatSync(entryPath) + if (stat.isSymbolicLink()) continue + if (stat.isDirectory()) assertSafeTree(entryPath) + else if (!stat.isFile()) throw new TypeError('插件数据迁移只允许普通文件和目录') + } +} + +function copyMissingTree(source, destination) { + fs.mkdirSync(destination, { recursive: true }) + for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + const sourcePath = path.join(source, entry.name) + const destinationPath = path.join(destination, entry.name) + if (entry.name === 'runtime' && fs.existsSync(destinationPath)) { + if (fs.lstatSync(destinationPath).isSymbolicLink()) throw new TypeError('runtime 目录不能是符号链接') + continue + } + if (fs.existsSync(destinationPath)) continue + fs.cpSync(sourcePath, destinationPath, { recursive: true, force: false, errorOnExist: true }) + } +} + +function verifySourceIncluded(source, destination) { + for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + const sourcePath = path.join(source, entry.name) + const destinationPath = path.join(destination, entry.name) + if (entry.name === 'runtime' && fs.existsSync(destinationPath)) { + if (fs.lstatSync(destinationPath).isSymbolicLink()) return false + continue + } + if (!fs.existsSync(destinationPath)) return false + const sourceStat = fs.lstatSync(sourcePath) + const destinationStat = fs.lstatSync(destinationPath) + if (sourceStat.isSymbolicLink() || destinationStat.isSymbolicLink()) { + if (!sourceStat.isSymbolicLink() || !destinationStat.isSymbolicLink() + || fs.readlinkSync(sourcePath) !== fs.readlinkSync(destinationPath)) return false + continue + } + if (sourceStat.isDirectory() !== destinationStat.isDirectory()) return false + if (sourceStat.isDirectory()) { + if (!verifySourceIncluded(sourcePath, destinationPath)) return false + } else if (!sourceStat.isFile() || !destinationStat.isFile() + || sourceStat.size !== destinationStat.size + || fileDigest(sourcePath) !== fileDigest(destinationPath)) return false + } + return true +} + +function migrateDefaultDataDir(api, legacyDataDir) { + let pluginData = '' + try { pluginData = typeof api?.getPath === 'function' ? api.getPath('pluginData') : '' } catch {} + if (typeof pluginData !== 'string' || !path.isAbsolute(pluginData)) { + return Object.freeze({ path: legacyDataDir, migrated: false, usingPluginData: false }) + } + const destination = path.resolve(pluginData) + const legacy = path.resolve(legacyDataDir) + if (destination === legacy) return Object.freeze({ path: destination, migrated: false, usingPluginData: true }) + let migrated = false + try { + if (fs.existsSync(legacy)) { + assertSafeTree(legacy) + copyMissingTree(legacy, destination) + if (!verifySourceIncluded(legacy, destination)) { + return Object.freeze({ path: legacy, migrated: false, usingPluginData: false }) + } + fs.rmSync(legacy, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + if (fs.existsSync(legacy)) return Object.freeze({ path: legacy, migrated: false, usingPluginData: false }) + migrated = true + } + fs.mkdirSync(destination, { recursive: true }) + fs.writeFileSync(path.join(destination, '.cc-switch-plugin-data-migration-v1.json'), JSON.stringify({ version: 1, completedAt: new Date().toISOString() })) + return Object.freeze({ path: destination, migrated, usingPluginData: true }) + } catch { + if (fs.existsSync(legacy)) return Object.freeze({ path: legacy, migrated: false, usingPluginData: false }) + if (fs.existsSync(destination)) return Object.freeze({ path: destination, migrated: true, usingPluginData: true }) + return Object.freeze({ path: legacy, migrated: false, usingPluginData: false }) + } +} + +module.exports = Object.freeze({ assertSafeTree, copyMissingTree, migrateDefaultDataDir, verifySourceIncluded }) 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(() => {