Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions plugins/ztools-cc-switch/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 8 additions & 1 deletion plugins/ztools-cc-switch/README.md
Original file line number Diff line number Diff line change
@@ -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 整流、用量与请求日志。
Expand Down Expand Up @@ -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。
Expand Down
2 changes: 1 addition & 1 deletion plugins/ztools-cc-switch/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ztools-cc-switch",
"version": "1.53.0",
"version": "1.54.0",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion plugins/ztools-cc-switch/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/ztools-cc-switch/preload/configManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* 设计原则:
* 1. 只修改各客户端明确支持的配置字段,保留用户的其他设置。
* 2. 所有写入都先生成 `.bak`,再通过同目录临时文件原子替换。
* 3. Provider 数据独立保存在 ZTools userData,不把密钥写进前端存储。
* 3. Provider 数据保存在插件专属数据目录,不把密钥写进前端存储。
*/

const fs = require('node:fs')
Expand Down
82 changes: 76 additions & 6 deletions plugins/ztools-cc-switch/preload/index.js
Original file line number Diff line number Diff line change
@@ -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 原语。
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 || {}),
Expand Down Expand Up @@ -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 || {})
})
Expand All @@ -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'))
})
}

Expand All @@ -663,3 +732,4 @@ setTimeout(() => {
}, 3000)

console.info('[cc-switch] preload ready')
}
92 changes: 92 additions & 0 deletions plugins/ztools-cc-switch/preload/pluginDataMigration.js
Original file line number Diff line number Diff line change
@@ -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 })
45 changes: 45 additions & 0 deletions plugins/ztools-cc-switch/preload/ztoolsCompatibility.js
Original file line number Diff line number Diff line change
@@ -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 })
Loading
Loading