mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
Merge 6d8af5a9a8 into fd6986076a
This commit is contained in:
commit
33ef25bb77
12
.github/workflows/main.yml
vendored
Normal file
12
.github/workflows/main.yml
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
- name: CodeX: PR Checks
|
||||
# You may pin to the exact commit or the version.
|
||||
# uses: codex-team/action-pr-assistant@6e7600580b84b2579eb9d97e399c1d323b97e921
|
||||
uses: codex-team/action-pr-assistant@1.0.1
|
||||
with:
|
||||
# Which check to run: description, linked-issue or both
|
||||
check: # optional, default is description
|
||||
# Modes: strict, draft or comment
|
||||
mode: # optional, default is comment
|
||||
# Optional token with extended permissions. If empty, GITHUB_TOKEN is used.
|
||||
token: # optional, default is
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -35,6 +35,7 @@ Thumbs.db
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.tmp/
|
||||
mcp_server
|
||||
stats.html
|
||||
.eslintcache
|
||||
@ -56,6 +57,8 @@ local
|
||||
.claude-code-router/*
|
||||
.codebuddy/*
|
||||
.zed/*
|
||||
.cache/
|
||||
.home/
|
||||
CLAUDE.local.md
|
||||
|
||||
# vitest
|
||||
|
||||
@ -175,7 +175,12 @@ if (!app.requestSingleInstanceLock()) {
|
||||
// Start API server if enabled or if agents exist
|
||||
try {
|
||||
const config = await apiServerService.getCurrentConfig()
|
||||
logger.info('API server config:', config)
|
||||
logger.info('API server config:', {
|
||||
enabled: config.enabled,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
hasApiKey: Boolean(config.apiKey)
|
||||
})
|
||||
|
||||
// Check if there are any agents
|
||||
let shouldStart = config.enabled
|
||||
|
||||
@ -149,6 +149,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
notesPath: getNotesDir(),
|
||||
configPath: getConfigDir(),
|
||||
appDataPath: app.getPath('userData'),
|
||||
documentsPath: app.getPath('documents'),
|
||||
downloadsPath: app.getPath('downloads'),
|
||||
desktopPath: app.getPath('desktop'),
|
||||
resourcesPath: getResourcePath(),
|
||||
logsPath: logger.getLogsDir(),
|
||||
arch: arch(),
|
||||
|
||||
@ -8,7 +8,7 @@ import path from 'node:path'
|
||||
import { loggerService } from '@logger'
|
||||
import { getConfigDir } from '@main/utils/file'
|
||||
import * as crypto from 'crypto'
|
||||
import { net, shell } from 'electron'
|
||||
import { net, safeStorage, shell } from 'electron'
|
||||
import { promises } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
|
||||
@ -117,15 +117,44 @@ class AnthropicService extends Error {
|
||||
// 5. Save credentials
|
||||
private async saveCredentials(creds: Credentials): Promise<void> {
|
||||
await promises.mkdir(dirname(CREDS_PATH), { recursive: true })
|
||||
await promises.writeFile(CREDS_PATH, JSON.stringify(creds, null, 2))
|
||||
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
logger.warn('safeStorage encryption is not available; saving Anthropic OAuth credentials as plain JSON')
|
||||
await promises.writeFile(CREDS_PATH, JSON.stringify(creds, null, 2))
|
||||
await promises.chmod(CREDS_PATH, 0o600) // Read/write for owner only
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const encrypted = safeStorage.encryptString(JSON.stringify(creds))
|
||||
await promises.writeFile(CREDS_PATH, encrypted)
|
||||
} catch (error) {
|
||||
logger.warn('safeStorage encryptString failed; saving Anthropic OAuth credentials as plain JSON', error as Error)
|
||||
await promises.writeFile(CREDS_PATH, JSON.stringify(creds, null, 2))
|
||||
}
|
||||
await promises.chmod(CREDS_PATH, 0o600) // Read/write for owner only
|
||||
}
|
||||
|
||||
// 6. Load credentials
|
||||
private async loadCredentials(): Promise<Credentials | null> {
|
||||
try {
|
||||
const data = await promises.readFile(CREDS_PATH, 'utf-8')
|
||||
return JSON.parse(data)
|
||||
const raw = await promises.readFile(CREDS_PATH)
|
||||
|
||||
// Prefer encrypted payload if supported.
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
const decrypted = safeStorage.decryptString(raw)
|
||||
return JSON.parse(decrypted) as Credentials
|
||||
} catch {
|
||||
// Fall back to legacy plain JSON (pre-encryption), and migrate on success.
|
||||
}
|
||||
}
|
||||
|
||||
const legacy = JSON.parse(raw.toString('utf-8')) as Credentials
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
await this.saveCredentials(legacy)
|
||||
}
|
||||
return legacy
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@ -163,7 +192,12 @@ class AnthropicService extends Error {
|
||||
|
||||
// Build authorization URL
|
||||
const authUrl = this.getAuthorizationURL(this.currentPKCE)
|
||||
logger.debug(authUrl)
|
||||
try {
|
||||
const parsed = new URL(authUrl)
|
||||
logger.debug('Starting Anthropic OAuth flow', { origin: parsed.origin, pathname: parsed.pathname })
|
||||
} catch {
|
||||
logger.debug('Starting Anthropic OAuth flow')
|
||||
}
|
||||
|
||||
// Open URL in external browser
|
||||
await shell.openExternal(authUrl)
|
||||
|
||||
@ -11,12 +11,17 @@ import * as path from 'path'
|
||||
import type { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import { getDataPath } from '../utils'
|
||||
import { decryptBackupFile, encryptBackupFile, isEncryptedBackupFile } from '../utils/backupEncryption'
|
||||
import S3Storage from './S3Storage'
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
const logger = loggerService.withContext('BackupManager')
|
||||
|
||||
type BackupCryptoOptions = {
|
||||
passphrase?: string
|
||||
}
|
||||
|
||||
class BackupManager {
|
||||
private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp')
|
||||
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
|
||||
@ -195,14 +200,19 @@ class BackupManager {
|
||||
fileName: string,
|
||||
data: string,
|
||||
destinationPath: string = this.backupDir,
|
||||
skipBackupFile: boolean = false
|
||||
skipBackupFile: boolean = false,
|
||||
options: BackupCryptoOptions = {}
|
||||
): Promise<string> {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
const passphrase =
|
||||
typeof options.passphrase === 'string' && options.passphrase.length > 0 ? options.passphrase : null
|
||||
let tempZipPath = ''
|
||||
let backupedFilePath = ''
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
|
||||
// 只在关键阶段记录日志:开始、结束和主要阶段转换点
|
||||
const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed']
|
||||
const logStages = ['preparing', 'writing_data', 'preparing_compression', 'encrypting', 'completed']
|
||||
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||
logger.debug('backup progress', processData)
|
||||
}
|
||||
@ -210,6 +220,7 @@ class BackupManager {
|
||||
|
||||
try {
|
||||
await fs.ensureDir(this.tempDir)
|
||||
await fs.ensureDir(destinationPath)
|
||||
onProgress({ stage: 'preparing', progress: 0, total: 100 })
|
||||
|
||||
// 使用流的方式写入 data.json
|
||||
@ -251,9 +262,11 @@ class BackupManager {
|
||||
await fs.promises.mkdir(path.join(this.tempDir, 'Data')) // 不创建空 Data 目录会导致 restore 失败
|
||||
}
|
||||
|
||||
backupedFilePath = path.join(destinationPath, fileName)
|
||||
tempZipPath = passphrase ? path.join(this.backupDir, `cherry-studio.${Date.now()}.zip`) : backupedFilePath
|
||||
|
||||
// 创建输出文件流
|
||||
const backupedFilePath = path.join(destinationPath, fileName)
|
||||
const output = fs.createWriteStream(backupedFilePath)
|
||||
const output = fs.createWriteStream(tempZipPath)
|
||||
|
||||
// 创建 archiver 实例,启用 ZIP64 支持
|
||||
const archive = archiver('zip', {
|
||||
@ -261,6 +274,7 @@ class BackupManager {
|
||||
zip64: true // 启用 ZIP64 支持以处理大文件
|
||||
})
|
||||
|
||||
const compressMax = passphrase ? 95 : 99
|
||||
let lastProgress = 50
|
||||
let totalEntries = 0
|
||||
let processedEntries = 0
|
||||
@ -305,7 +319,10 @@ class BackupManager {
|
||||
archive.on('data', (chunk) => {
|
||||
processedBytes += chunk.length
|
||||
if (totalBytes > 0) {
|
||||
const progressPercent = Math.min(99, 55 + Math.floor((processedBytes / totalBytes) * 44))
|
||||
const progressPercent = Math.min(
|
||||
compressMax,
|
||||
55 + Math.floor((processedBytes / totalBytes) * (compressMax - 55))
|
||||
)
|
||||
if (progressPercent > lastProgress) {
|
||||
lastProgress = progressPercent
|
||||
onProgress({ stage: 'compressing', progress: progressPercent, total: 100 })
|
||||
@ -316,7 +333,7 @@ class BackupManager {
|
||||
// 使用 Promise 等待压缩完成
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
output.on('close', () => {
|
||||
onProgress({ stage: 'compressing', progress: 100, total: 100 })
|
||||
onProgress({ stage: 'compressing', progress: compressMax, total: 100 })
|
||||
resolve()
|
||||
})
|
||||
archive.on('error', reject)
|
||||
@ -336,6 +353,26 @@ class BackupManager {
|
||||
archive.finalize()
|
||||
})
|
||||
|
||||
if (passphrase) {
|
||||
const encryptStart = compressMax
|
||||
const encryptSpan = 99 - encryptStart
|
||||
onProgress({ stage: 'encrypting', progress: encryptStart, total: 100 })
|
||||
|
||||
await encryptBackupFile(tempZipPath, backupedFilePath, {
|
||||
passphrase,
|
||||
onProgress: (processed, total) => {
|
||||
if (total <= 0) return
|
||||
const progressPercent = Math.min(99, encryptStart + Math.floor((processed / total) * encryptSpan))
|
||||
if (progressPercent > lastProgress) {
|
||||
lastProgress = progressPercent
|
||||
onProgress({ stage: 'encrypting', progress: progressPercent, total: 100 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await fs.remove(tempZipPath).catch(() => {})
|
||||
}
|
||||
|
||||
// 清理临时目录
|
||||
await fs.remove(this.tempDir)
|
||||
onProgress({ stage: 'completed', progress: 100, total: 100 })
|
||||
@ -346,17 +383,30 @@ class BackupManager {
|
||||
logger.error('[BackupManager] Backup failed:', error as Error)
|
||||
// 确保清理临时目录
|
||||
await fs.remove(this.tempDir).catch(() => {})
|
||||
if (tempZipPath) {
|
||||
await fs.remove(tempZipPath).catch(() => {})
|
||||
}
|
||||
if (backupedFilePath) {
|
||||
await fs.remove(backupedFilePath).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> {
|
||||
async restore(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
backupPath: string,
|
||||
options: BackupCryptoOptions = {}
|
||||
): Promise<string> {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
const passphrase =
|
||||
typeof options.passphrase === 'string' && options.passphrase.length > 0 ? options.passphrase : null
|
||||
let decryptedZipPath: string | null = null
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
|
||||
// 只在关键阶段记录日志
|
||||
const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed']
|
||||
const logStages = ['preparing', 'decrypting', 'extracting', 'extracted', 'reading_data', 'completed']
|
||||
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||
logger.debug('restore progress', processData)
|
||||
}
|
||||
@ -367,9 +417,37 @@ class BackupManager {
|
||||
await fs.ensureDir(this.tempDir)
|
||||
onProgress({ stage: 'preparing', progress: 0, total: 100 })
|
||||
|
||||
let zipPath = backupPath
|
||||
|
||||
const encrypted = await isEncryptedBackupFile(backupPath)
|
||||
if (encrypted) {
|
||||
if (!passphrase) {
|
||||
throw new Error('Backup passphrase required')
|
||||
}
|
||||
|
||||
decryptedZipPath = path.join(this.backupDir, `cherry-studio.restore.${Date.now()}.zip`)
|
||||
onProgress({ stage: 'decrypting', progress: 10, total: 100 })
|
||||
|
||||
try {
|
||||
await decryptBackupFile(backupPath, decryptedZipPath, {
|
||||
passphrase,
|
||||
onProgress: (processed, total) => {
|
||||
if (total <= 0) return
|
||||
const progress = Math.min(14, 10 + Math.floor((processed / total) * 4))
|
||||
onProgress({ stage: 'decrypting', progress, total: 100 })
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
await fs.remove(decryptedZipPath).catch(() => {})
|
||||
throw new Error('Invalid backup passphrase')
|
||||
}
|
||||
|
||||
zipPath = decryptedZipPath
|
||||
}
|
||||
|
||||
logger.debug(`step 1: unzip backup file: ${this.tempDir}`)
|
||||
|
||||
const zip = new StreamZip.async({ file: backupPath })
|
||||
const zip = new StreamZip.async({ file: zipPath })
|
||||
onProgress({ stage: 'extracting', progress: 15, total: 100 })
|
||||
await zip.extract(null, this.tempDir)
|
||||
onProgress({ stage: 'extracted', progress: 25, total: 100 })
|
||||
@ -410,6 +488,9 @@ class BackupManager {
|
||||
// 清理临时目录
|
||||
await this.setWritableRecursive(this.tempDir)
|
||||
await fs.remove(this.tempDir)
|
||||
if (decryptedZipPath) {
|
||||
await fs.remove(decryptedZipPath).catch(() => {})
|
||||
}
|
||||
onProgress({ stage: 'completed', progress: 100, total: 100 })
|
||||
|
||||
logger.debug('step 5: Restore completed successfully')
|
||||
@ -418,6 +499,9 @@ class BackupManager {
|
||||
} catch (error) {
|
||||
logger.error('Restore failed:', error as Error)
|
||||
await fs.remove(this.tempDir).catch(() => {})
|
||||
if (decryptedZipPath) {
|
||||
await fs.remove(decryptedZipPath).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,6 +85,13 @@ class CopilotService {
|
||||
return path.join(getConfigDir(), CONFIG.TOKEN_FILE_NAME)
|
||||
}
|
||||
|
||||
private isLikelyToken = (token: string): boolean => {
|
||||
if (!token) return false
|
||||
if (token !== token.trim()) return false
|
||||
if (token.length < 10) return false
|
||||
return /^[\x21-\x7E]+$/.test(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置自定义请求头
|
||||
*/
|
||||
@ -218,14 +225,25 @@ class CopilotService {
|
||||
*/
|
||||
public saveCopilotToken = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<void> => {
|
||||
try {
|
||||
const encryptedToken = safeStorage.encryptString(token)
|
||||
let payload: Buffer | string = token
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
payload = safeStorage.encryptString(token)
|
||||
} catch (error) {
|
||||
logger.warn('safeStorage encryptString failed; saving Copilot token as plain text', error as Error)
|
||||
payload = token
|
||||
}
|
||||
} else {
|
||||
logger.warn('safeStorage encryption is not available; saving Copilot token as plain text')
|
||||
}
|
||||
// 确保目录存在
|
||||
const dir = path.dirname(this.tokenFilePath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(this.tokenFilePath, encryptedToken)
|
||||
await fs.promises.writeFile(this.tokenFilePath, payload)
|
||||
await fs.promises.chmod(this.tokenFilePath, 0o600).catch(() => {})
|
||||
} catch (error) {
|
||||
logger.error('Failed to save token:', error as Error)
|
||||
throw new CopilotServiceError('无法保存访问令牌', error)
|
||||
@ -242,8 +260,33 @@ class CopilotService {
|
||||
try {
|
||||
this.updateHeaders(headers)
|
||||
|
||||
const encryptedToken = await fs.promises.readFile(this.tokenFilePath)
|
||||
const access_token = safeStorage.decryptString(Buffer.from(encryptedToken))
|
||||
const raw = await fs.promises.readFile(this.tokenFilePath)
|
||||
|
||||
let access_token: string | undefined
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
access_token = safeStorage.decryptString(raw)
|
||||
} catch {
|
||||
// Fall back to legacy plain token (pre-encryption), and migrate on success.
|
||||
}
|
||||
}
|
||||
|
||||
if (!access_token) {
|
||||
const legacy = raw.toString('utf-8')
|
||||
if (!this.isLikelyToken(legacy)) {
|
||||
await fs.promises.unlink(this.tokenFilePath).catch(() => {})
|
||||
throw new CopilotServiceError('无法读取已保存的访问令牌,请重新授权')
|
||||
}
|
||||
access_token = legacy
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
await this.saveCopilotToken(_, legacy)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isLikelyToken(access_token)) {
|
||||
await fs.promises.unlink(this.tokenFilePath).catch(() => {})
|
||||
throw new CopilotServiceError('无法读取已保存的访问令牌,请重新授权')
|
||||
}
|
||||
|
||||
const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, {
|
||||
method: 'GET',
|
||||
|
||||
@ -24,8 +24,9 @@ export class SearchService {
|
||||
height: 600,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
devTools: is.dev
|
||||
}
|
||||
})
|
||||
|
||||
@ -3,7 +3,7 @@ import './ThemeService'
|
||||
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { loggerService } from '@logger'
|
||||
import { isDev, isLinux, isMac, isWin } from '@main/constant'
|
||||
import { isLinux, isMac, isWin } from '@main/constant'
|
||||
import { getFilesDir } from '@main/utils/file'
|
||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
@ -82,10 +82,12 @@ export class WindowService {
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
allowRunningInsecureContent: false,
|
||||
webviewTag: true,
|
||||
allowRunningInsecureContent: true,
|
||||
zoomFactor: configManager.getZoomFactor(),
|
||||
backgroundThrottling: false
|
||||
}
|
||||
@ -164,12 +166,15 @@ export class WindowService {
|
||||
contextMenu.contextMenu(webContents)
|
||||
})
|
||||
|
||||
// Dangerous API
|
||||
if (isDev) {
|
||||
mainWindow.webContents.on('will-attach-webview', (_, webPreferences) => {
|
||||
webPreferences.preload = join(__dirname, '../preload/index.js')
|
||||
})
|
||||
}
|
||||
mainWindow.webContents.on('will-attach-webview', (_, webPreferences) => {
|
||||
// Never expose the app preload API surface to arbitrary <webview> content.
|
||||
delete (webPreferences as any).preload
|
||||
webPreferences.nodeIntegration = false
|
||||
webPreferences.contextIsolation = true
|
||||
webPreferences.sandbox = true
|
||||
webPreferences.webSecurity = true
|
||||
webPreferences.allowRunningInsecureContent = false
|
||||
})
|
||||
}
|
||||
|
||||
private setupWindowEvents(mainWindow: BrowserWindow) {
|
||||
@ -284,7 +289,10 @@ export class WindowService {
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
webPreferences: {
|
||||
partition: 'persist:webview'
|
||||
partition: 'persist:webview',
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -301,26 +309,6 @@ export class WindowService {
|
||||
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
this.setupWebRequestHeaders(mainWindow)
|
||||
}
|
||||
|
||||
private setupWebRequestHeaders(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
if (details.responseHeaders?.['X-Frame-Options']) {
|
||||
delete details.responseHeaders['X-Frame-Options']
|
||||
}
|
||||
if (details.responseHeaders?.['x-frame-options']) {
|
||||
delete details.responseHeaders['x-frame-options']
|
||||
}
|
||||
if (details.responseHeaders?.['Content-Security-Policy']) {
|
||||
delete details.responseHeaders['Content-Security-Policy']
|
||||
}
|
||||
if (details.responseHeaders?.['content-security-policy']) {
|
||||
delete details.responseHeaders['content-security-policy']
|
||||
}
|
||||
callback({ cancel: false, responseHeaders: details.responseHeaders })
|
||||
})
|
||||
}
|
||||
|
||||
private loadMainWindowContent(mainWindow: BrowserWindow) {
|
||||
@ -505,9 +493,12 @@ export class WindowService {
|
||||
fullscreenable: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: false,
|
||||
webviewTag: true
|
||||
allowRunningInsecureContent: false,
|
||||
webviewTag: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
84
src/main/services/__tests__/CopilotService.test.ts
Normal file
84
src/main/services/__tests__/CopilotService.test.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { net, safeStorage } from 'electron'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
chmod: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
access: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({ default: fsMock, ...fsMock }))
|
||||
|
||||
import copilotService from '../CopilotService'
|
||||
|
||||
describe('CopilotService token storage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true)
|
||||
vi.mocked(fsMock.promises.mkdir).mockResolvedValue(undefined)
|
||||
vi.mocked(fsMock.promises.writeFile).mockResolvedValue(undefined)
|
||||
vi.mocked(fsMock.promises.chmod).mockResolvedValue(undefined)
|
||||
vi.mocked(fsMock.promises.unlink).mockResolvedValue(undefined)
|
||||
vi.mocked(net.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ token: 'copilot-token' })
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('saves Copilot token encrypted when safeStorage is available', async () => {
|
||||
const ciphertext = Buffer.from('ciphertext', 'utf-8')
|
||||
vi.mocked(safeStorage.encryptString).mockReturnValue(ciphertext)
|
||||
|
||||
await copilotService.saveCopilotToken({} as any, 'ghp_abcdefghijklmnopqrstuvwxyz1234567890')
|
||||
|
||||
expect(fsMock.promises.writeFile).toHaveBeenCalledWith(expect.stringContaining('.copilot_token'), ciphertext)
|
||||
expect(fsMock.promises.chmod).toHaveBeenCalledWith(expect.stringContaining('.copilot_token'), 0o600)
|
||||
})
|
||||
|
||||
it('reads encrypted Copilot token and uses it for authorization', async () => {
|
||||
vi.mocked(fsMock.promises.readFile).mockResolvedValue(Buffer.from('encrypted:anything', 'utf-8'))
|
||||
vi.mocked(safeStorage.decryptString).mockReturnValue('ghp_abcdefghijklmnopqrstuvwxyz1234567890')
|
||||
|
||||
await copilotService.getToken({} as any)
|
||||
|
||||
expect(net.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('copilot_internal'),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
authorization: 'token ghp_abcdefghijklmnopqrstuvwxyz1234567890'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to legacy plaintext token and migrates it to encrypted storage', async () => {
|
||||
const legacyToken = 'ghp_abcdefghijklmnopqrstuvwxyz1234567890'
|
||||
vi.mocked(fsMock.promises.readFile).mockResolvedValue(Buffer.from(legacyToken, 'utf-8'))
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation(() => {
|
||||
throw new Error('Unable to decrypt')
|
||||
})
|
||||
|
||||
const ciphertext = Buffer.from('ciphertext', 'utf-8')
|
||||
vi.mocked(safeStorage.encryptString).mockReturnValue(ciphertext)
|
||||
|
||||
await copilotService.getToken({} as any)
|
||||
|
||||
expect(fsMock.promises.writeFile).toHaveBeenCalledWith(expect.stringContaining('.copilot_token'), ciphertext)
|
||||
})
|
||||
|
||||
it('clears unreadable token files when decryption fails and plaintext fallback is invalid', async () => {
|
||||
vi.mocked(fsMock.promises.readFile).mockResolvedValue(Buffer.from('not a token', 'utf-8'))
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation(() => {
|
||||
throw new Error('Key unavailable')
|
||||
})
|
||||
|
||||
await expect(copilotService.getToken({} as any)).rejects.toThrow('无法获取Copilot令牌')
|
||||
expect(fsMock.promises.unlink).toHaveBeenCalledWith(expect.stringContaining('.copilot_token'))
|
||||
})
|
||||
})
|
||||
67
src/main/services/mcp/oauth/__tests__/storage.test.ts
Normal file
67
src/main/services/mcp/oauth/__tests__/storage.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { safeStorage } from 'electron'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { JsonFileStorage } from '../storage'
|
||||
|
||||
vi.mock('fs/promises', () => {
|
||||
const api = {
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
chmod: vi.fn(),
|
||||
unlink: vi.fn()
|
||||
}
|
||||
return { default: api, ...api }
|
||||
})
|
||||
|
||||
describe('MCP OAuth JsonFileStorage', () => {
|
||||
const configDir = '/mock/config'
|
||||
const serverUrlHash = 'serverHash'
|
||||
const filePath = path.join(configDir, `${serverUrlHash}_oauth.json`)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('migrates legacy plaintext JSON to encrypted storage when available', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from(JSON.stringify({ lastUpdated: 1 }), 'utf-8'))
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.rename).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.chmod).mockResolvedValue(undefined)
|
||||
|
||||
const ciphertext = Buffer.from('ciphertext', 'utf-8')
|
||||
vi.mocked(safeStorage.encryptString).mockReturnValue(ciphertext)
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation(() => {
|
||||
throw new Error('Unable to decrypt')
|
||||
})
|
||||
|
||||
const storage = new JsonFileStorage(serverUrlHash, configDir)
|
||||
await storage.getTokens()
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(`${filePath}.tmp`, ciphertext)
|
||||
expect(fs.rename).toHaveBeenCalledWith(`${filePath}.tmp`, filePath)
|
||||
expect(fs.chmod).toHaveBeenCalledWith(filePath, 0o600)
|
||||
})
|
||||
|
||||
it('quarantines unreadable storage and resets to an empty state', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('not-json', 'utf-8'))
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.rename).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.chmod).mockResolvedValue(undefined)
|
||||
vi.mocked(safeStorage.decryptString).mockImplementation(() => {
|
||||
throw new Error('Key unavailable')
|
||||
})
|
||||
|
||||
const storage = new JsonFileStorage(serverUrlHash, configDir)
|
||||
await expect(storage.getTokens()).resolves.toBeUndefined()
|
||||
|
||||
expect(fs.rename).toHaveBeenCalledWith(filePath, expect.stringContaining(`${filePath}.unreadable.`))
|
||||
expect(fs.writeFile).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -4,6 +4,7 @@ import type {
|
||||
OAuthClientInformationFull,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import { safeStorage } from 'electron'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
@ -23,16 +24,61 @@ export class JsonFileStorage implements IOAuthStorage {
|
||||
this.filePath = path.join(configDir, `${serverUrlHash}_oauth.json`)
|
||||
}
|
||||
|
||||
private async quarantineUnreadableFile(reason: string): Promise<void> {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replaceAll(':', '-')
|
||||
const quarantinePath = `${this.filePath}.unreadable.${timestamp}`
|
||||
await fs.rename(this.filePath, quarantinePath)
|
||||
logger.warn(`OAuth storage was unreadable and has been quarantined (${reason})`, { quarantinePath })
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to quarantine unreadable OAuth storage (${reason})`, error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
private async readStorage(): Promise<OAuthStorageData> {
|
||||
if (this.cache) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(this.filePath, 'utf-8')
|
||||
const parsed = JSON.parse(data)
|
||||
const validated = OAuthStorageSchema.parse(parsed)
|
||||
const raw = await fs.readFile(this.filePath)
|
||||
|
||||
const candidates: Array<{ source: 'encrypted' | 'plain'; payload: string }> = []
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
candidates.push({ source: 'encrypted', payload: safeStorage.decryptString(raw) })
|
||||
} catch {
|
||||
// Decryption failure can happen when the OS keychain entry is unavailable (e.g., different OS user, reinstall).
|
||||
}
|
||||
}
|
||||
candidates.push({ source: 'plain', payload: raw.toString('utf-8') })
|
||||
|
||||
let validated: OAuthStorageData | null = null
|
||||
let usedEncryptedPayload = false
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate.payload)
|
||||
validated = OAuthStorageSchema.parse(parsed)
|
||||
usedEncryptedPayload = candidate.source === 'encrypted'
|
||||
break
|
||||
} catch {
|
||||
// Keep trying the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
if (!validated) {
|
||||
await this.quarantineUnreadableFile('invalid_json_or_decryption_failed')
|
||||
const initial: OAuthStorageData = { lastUpdated: Date.now() }
|
||||
await this.writeStorage(initial)
|
||||
return initial
|
||||
}
|
||||
|
||||
this.cache = validated
|
||||
|
||||
if (safeStorage.isEncryptionAvailable() && !usedEncryptedPayload) {
|
||||
await this.writeStorage(validated)
|
||||
}
|
||||
|
||||
return validated
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
@ -56,8 +102,20 @@ export class JsonFileStorage implements IOAuthStorage {
|
||||
|
||||
// Write file atomically
|
||||
const tempPath = `${this.filePath}.tmp`
|
||||
await fs.writeFile(tempPath, JSON.stringify(data, null, 2))
|
||||
const payload = JSON.stringify(data, null, 2)
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
await fs.writeFile(tempPath, safeStorage.encryptString(payload))
|
||||
} catch (error) {
|
||||
logger.warn('safeStorage encryptString failed; saving MCP OAuth storage as plain JSON', error as Error)
|
||||
await fs.writeFile(tempPath, payload)
|
||||
}
|
||||
} else {
|
||||
logger.warn('safeStorage encryption is not available; saving MCP OAuth storage as plain JSON')
|
||||
await fs.writeFile(tempPath, payload)
|
||||
}
|
||||
await fs.rename(tempPath, this.filePath)
|
||||
await fs.chmod(this.filePath, 0o600)
|
||||
|
||||
// Update cache
|
||||
this.cache = data
|
||||
|
||||
59
src/main/utils/__tests__/backupEncryption.test.ts
Normal file
59
src/main/utils/__tests__/backupEncryption.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import * as fs from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { decryptBackupFile, encryptBackupFile, isEncryptedBackupFile } from '../backupEncryption'
|
||||
|
||||
describe('backupEncryption', () => {
|
||||
it('encrypts and decrypts backup files', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(process.cwd(), 'tmp-backup-encryption-'))
|
||||
try {
|
||||
const inputPath = path.join(tempDir, 'plain.zip')
|
||||
const encryptedPath = path.join(tempDir, 'backup.csbackup')
|
||||
const outputPath = path.join(tempDir, 'decrypted.zip')
|
||||
|
||||
await fs.writeFile(inputPath, Buffer.from('hello world', 'utf-8'))
|
||||
|
||||
await encryptBackupFile(inputPath, encryptedPath, { passphrase: 'test-passphrase', iterations: 1000 })
|
||||
expect(await isEncryptedBackupFile(encryptedPath)).toBe(true)
|
||||
|
||||
await decryptBackupFile(encryptedPath, outputPath, { passphrase: 'test-passphrase' })
|
||||
const decrypted = await fs.readFile(outputPath, 'utf-8')
|
||||
expect(decrypted).toBe('hello world')
|
||||
} finally {
|
||||
await fs.remove(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
it('fails to decrypt with a wrong passphrase', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(process.cwd(), 'tmp-backup-encryption-'))
|
||||
try {
|
||||
const inputPath = path.join(tempDir, 'plain.zip')
|
||||
const encryptedPath = path.join(tempDir, 'backup.csbackup')
|
||||
const outputPath = path.join(tempDir, 'decrypted.zip')
|
||||
|
||||
await fs.writeFile(inputPath, Buffer.from('hello world', 'utf-8'))
|
||||
await encryptBackupFile(inputPath, encryptedPath, { passphrase: 'correct', iterations: 1000 })
|
||||
|
||||
await expect(decryptBackupFile(encryptedPath, outputPath, { passphrase: 'wrong' })).rejects.toBeTruthy()
|
||||
} finally {
|
||||
await fs.remove(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
it('detects encrypted backup file magic', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(process.cwd(), 'tmp-backup-encryption-'))
|
||||
try {
|
||||
const plainPath = path.join(tempDir, 'plain.zip')
|
||||
const encryptedPath = path.join(tempDir, 'backup.csbackup')
|
||||
|
||||
await fs.writeFile(plainPath, Buffer.from('plain', 'utf-8'))
|
||||
await encryptBackupFile(plainPath, encryptedPath, { passphrase: 'test', iterations: 1000 })
|
||||
|
||||
expect(await isEncryptedBackupFile(plainPath)).toBe(false)
|
||||
expect(await isEncryptedBackupFile(encryptedPath)).toBe(true)
|
||||
} finally {
|
||||
await fs.remove(tempDir)
|
||||
}
|
||||
})
|
||||
})
|
||||
178
src/main/utils/backupEncryption.ts
Normal file
178
src/main/utils/backupEncryption.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { open as openFile } from 'node:fs/promises'
|
||||
import { Transform } from 'node:stream'
|
||||
|
||||
import * as crypto from 'crypto'
|
||||
import * as fs from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
import { pipeline } from 'stream/promises'
|
||||
|
||||
const MAGIC = Buffer.from('CSBACKUP', 'utf-8')
|
||||
const VERSION = 1
|
||||
const DEFAULT_ITERATIONS = 200_000
|
||||
const SALT_LENGTH = 16
|
||||
const IV_LENGTH = 12
|
||||
const TAG_LENGTH = 16
|
||||
|
||||
const headerLength = MAGIC.length + 1 + 4 + SALT_LENGTH + IV_LENGTH
|
||||
|
||||
const deriveKey = (passphrase: string, salt: Buffer, iterations: number): Promise<Buffer> =>
|
||||
new Promise((resolve, reject) => {
|
||||
crypto.pbkdf2(passphrase, salt, iterations, 32, 'sha256', (error, derivedKey) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(derivedKey)
|
||||
})
|
||||
})
|
||||
|
||||
const buildHeader = (salt: Buffer, iv: Buffer, iterations: number): Buffer => {
|
||||
const header = Buffer.alloc(headerLength)
|
||||
let offset = 0
|
||||
MAGIC.copy(header, offset)
|
||||
offset += MAGIC.length
|
||||
header.writeUInt8(VERSION, offset)
|
||||
offset += 1
|
||||
header.writeUInt32BE(iterations, offset)
|
||||
offset += 4
|
||||
salt.copy(header, offset)
|
||||
offset += SALT_LENGTH
|
||||
iv.copy(header, offset)
|
||||
return header
|
||||
}
|
||||
|
||||
export const isEncryptedBackupFile = async (filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
const fd = await openFile(filePath, 'r')
|
||||
try {
|
||||
const magic = Buffer.alloc(MAGIC.length)
|
||||
const { bytesRead } = await fd.read(magic, 0, MAGIC.length, 0)
|
||||
if (bytesRead !== MAGIC.length) {
|
||||
return false
|
||||
}
|
||||
return magic.equals(MAGIC)
|
||||
} finally {
|
||||
await fd.close()
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export type BackupEncryptionOptions = {
|
||||
passphrase: string
|
||||
iterations?: number
|
||||
onProgress?: (processedBytes: number, totalBytes: number) => void
|
||||
}
|
||||
|
||||
export const encryptBackupFile = async (
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
options: BackupEncryptionOptions
|
||||
): Promise<void> => {
|
||||
const iterations = options.iterations ?? DEFAULT_ITERATIONS
|
||||
const salt = crypto.randomBytes(SALT_LENGTH)
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
const header = buildHeader(salt, iv, iterations)
|
||||
const key = await deriveKey(options.passphrase, salt, iterations)
|
||||
|
||||
await fs.ensureDir(path.dirname(outputPath))
|
||||
|
||||
const totalBytes = (await fs.stat(inputPath)).size
|
||||
let processedBytes = 0
|
||||
|
||||
const progressStream = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
processedBytes += chunk.length
|
||||
options.onProgress?.(processedBytes, totalBytes)
|
||||
callback(null, chunk)
|
||||
}
|
||||
})
|
||||
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
|
||||
cipher.setAAD(header)
|
||||
|
||||
await fs.writeFile(outputPath, header)
|
||||
await pipeline(
|
||||
fs.createReadStream(inputPath),
|
||||
progressStream,
|
||||
cipher,
|
||||
fs.createWriteStream(outputPath, { flags: 'a' })
|
||||
)
|
||||
|
||||
const tag = cipher.getAuthTag()
|
||||
await fs.appendFile(outputPath, tag)
|
||||
}
|
||||
|
||||
export const decryptBackupFile = async (
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
options: BackupEncryptionOptions
|
||||
): Promise<void> => {
|
||||
const fileStat = await fs.stat(inputPath)
|
||||
const fileSize = fileStat.size
|
||||
|
||||
if (fileSize <= headerLength + TAG_LENGTH) {
|
||||
throw new Error('Invalid encrypted backup file')
|
||||
}
|
||||
|
||||
const fd = await openFile(inputPath, 'r')
|
||||
let header: Buffer
|
||||
let tag: Buffer
|
||||
try {
|
||||
header = Buffer.alloc(headerLength)
|
||||
const headerRead = await fd.read(header, 0, headerLength, 0)
|
||||
if (headerRead.bytesRead !== headerLength) {
|
||||
throw new Error('Invalid encrypted backup header')
|
||||
}
|
||||
const magic = header.subarray(0, MAGIC.length)
|
||||
if (!magic.equals(MAGIC)) {
|
||||
throw new Error('Unsupported backup file')
|
||||
}
|
||||
|
||||
const version = header.readUInt8(MAGIC.length)
|
||||
if (version !== VERSION) {
|
||||
throw new Error('Unsupported encrypted backup version')
|
||||
}
|
||||
|
||||
tag = Buffer.alloc(TAG_LENGTH)
|
||||
const tagRead = await fd.read(tag, 0, TAG_LENGTH, fileSize - TAG_LENGTH)
|
||||
if (tagRead.bytesRead !== TAG_LENGTH) {
|
||||
throw new Error('Invalid encrypted backup tag')
|
||||
}
|
||||
} finally {
|
||||
await fd.close()
|
||||
}
|
||||
|
||||
const iterations = header.readUInt32BE(MAGIC.length + 1)
|
||||
const saltStart = MAGIC.length + 1 + 4
|
||||
const salt = header.subarray(saltStart, saltStart + SALT_LENGTH)
|
||||
const iv = header.subarray(saltStart + SALT_LENGTH, saltStart + SALT_LENGTH + IV_LENGTH)
|
||||
const key = await deriveKey(options.passphrase, salt, iterations)
|
||||
|
||||
await fs.ensureDir(path.dirname(outputPath))
|
||||
|
||||
const cipherTextStart = headerLength
|
||||
const cipherTextEnd = fileSize - TAG_LENGTH - 1
|
||||
const totalBytes = cipherTextEnd - cipherTextStart + 1
|
||||
let processedBytes = 0
|
||||
|
||||
const progressStream = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
processedBytes += chunk.length
|
||||
options.onProgress?.(processedBytes, totalBytes)
|
||||
callback(null, chunk)
|
||||
}
|
||||
})
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAAD(header)
|
||||
decipher.setAuthTag(tag)
|
||||
|
||||
await pipeline(
|
||||
fs.createReadStream(inputPath, { start: cipherTextStart, end: cipherTextEnd }),
|
||||
progressStream,
|
||||
decipher,
|
||||
fs.createWriteStream(outputPath)
|
||||
)
|
||||
}
|
||||
@ -35,7 +35,7 @@ import type {
|
||||
WebDavConfig
|
||||
} from '@types'
|
||||
import type { OpenDialogOptions } from 'electron'
|
||||
import { contextBridge, ipcRenderer, shell, webUtils } from 'electron'
|
||||
import { contextBridge, ipcRenderer, safeStorage, shell, webUtils } from 'electron'
|
||||
import type { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
import type {
|
||||
@ -138,9 +138,15 @@ const api = {
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
|
||||
},
|
||||
backup: {
|
||||
backup: (filename: string, content: string, path: string, skipBackupFile: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile),
|
||||
restore: (path: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, path),
|
||||
backup: (
|
||||
filename: string,
|
||||
content: string,
|
||||
path: string,
|
||||
skipBackupFile: boolean,
|
||||
options?: { passphrase?: string }
|
||||
) => ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile, options),
|
||||
restore: (path: string, options?: { passphrase?: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_Restore, path, options),
|
||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
|
||||
restoreFromWebdav: (webdavConfig: WebDavConfig) =>
|
||||
@ -395,6 +401,38 @@ const api = {
|
||||
shell: {
|
||||
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||
encryptString: (plainText: string) => {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return plainText
|
||||
}
|
||||
if (plainText.startsWith('csenc:')) {
|
||||
return plainText
|
||||
}
|
||||
try {
|
||||
const encrypted = safeStorage.encryptString(plainText)
|
||||
return `csenc:${encrypted.toString('base64')}`
|
||||
} catch {
|
||||
return plainText
|
||||
}
|
||||
},
|
||||
decryptString: (value: string) => {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return value
|
||||
}
|
||||
const prefix = 'csenc:'
|
||||
if (!value.startsWith(prefix)) {
|
||||
return value
|
||||
}
|
||||
try {
|
||||
const payload = value.slice(prefix.length)
|
||||
return safeStorage.decryptString(Buffer.from(payload, 'base64'))
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
},
|
||||
copilot: {
|
||||
getAuthMessage: (headers?: Record<string, string>) =>
|
||||
ipcRenderer.invoke(IpcChannel.Copilot_GetAuthMessage, headers),
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { getBackupProgressLabel } from '@renderer/i18n/label'
|
||||
import { backup } from '@renderer/services/BackupService'
|
||||
import { backupWithOptions } from '@renderer/services/BackupService'
|
||||
import store from '@renderer/store'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Modal, Progress } from 'antd'
|
||||
import { Alert, Checkbox, Input, Modal, Progress, Radio, Space, Typography } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -15,7 +15,14 @@ interface Props {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
type ProgressStageType = 'reading_data' | 'preparing' | 'extracting' | 'extracted' | 'copying_files' | 'completed'
|
||||
type ProgressStageType =
|
||||
| 'preparing'
|
||||
| 'writing_data'
|
||||
| 'copying_files'
|
||||
| 'preparing_compression'
|
||||
| 'compressing'
|
||||
| 'encrypting'
|
||||
| 'completed'
|
||||
|
||||
interface ProgressData {
|
||||
stage: ProgressStageType
|
||||
@ -26,6 +33,12 @@ interface ProgressData {
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [progressData, setProgressData] = useState<ProgressData>()
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [includeSecrets, setIncludeSecrets] = useState(false)
|
||||
const [encryptBackup, setEncryptBackup] = useState(true)
|
||||
const [secretsAcknowledged, setSecretsAcknowledged] = useState(false)
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
const [confirmPassphrase, setConfirmPassphrase] = useState('')
|
||||
const { t } = useTranslation()
|
||||
const skipBackupFile = store.getState().settings.skipBackupFile
|
||||
|
||||
@ -41,8 +54,22 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
|
||||
const onOk = async () => {
|
||||
logger.debug(`skipBackupFile: ${skipBackupFile}`)
|
||||
await backup(skipBackupFile)
|
||||
setOpen(false)
|
||||
setIsRunning(true)
|
||||
try {
|
||||
const completed = await backupWithOptions(skipBackupFile, {
|
||||
includeSecrets,
|
||||
passphrase: includeSecrets && encryptBackup ? passphrase : undefined
|
||||
})
|
||||
if (completed) {
|
||||
setOpen(false)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Backup failed:', error as Error)
|
||||
window.toast.error(t('message.backup.failed'))
|
||||
setProgressData(undefined)
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
@ -66,7 +93,14 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
|
||||
BackupPopup.hide = onCancel
|
||||
|
||||
const isDisabled = progressData ? progressData.stage !== 'completed' : false
|
||||
const needsPassphrase = includeSecrets && encryptBackup
|
||||
const passphraseValid = !needsPassphrase || (passphrase.length > 0 && passphrase === confirmPassphrase)
|
||||
const canStart = !includeSecrets || (secretsAcknowledged && passphraseValid)
|
||||
|
||||
const isProgressLocked = progressData ? progressData.stage !== 'completed' : false
|
||||
const isBusy = isRunning || isProgressLocked
|
||||
const okDisabled = isBusy || !canStart
|
||||
const cancelDisabled = isBusy
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -75,13 +109,68 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
okButtonProps={{ disabled: isDisabled }}
|
||||
cancelButtonProps={{ disabled: isDisabled }}
|
||||
okButtonProps={{ disabled: okDisabled }}
|
||||
cancelButtonProps={{ disabled: cancelDisabled }}
|
||||
okText={t('backup.confirm.button')}
|
||||
maskClosable={false}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
{!progressData && <div>{t('backup.content')}</div>}
|
||||
{!progressData && (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Typography.Paragraph style={{ marginBottom: 0 }}>{t('backup.content')}</Typography.Paragraph>
|
||||
<Radio.Group
|
||||
value={includeSecrets ? 'with_secrets' : 'without_secrets'}
|
||||
onChange={(e) => {
|
||||
const nextInclude = e.target.value === 'with_secrets'
|
||||
setIncludeSecrets(nextInclude)
|
||||
if (!nextInclude) {
|
||||
setSecretsAcknowledged(false)
|
||||
setPassphrase('')
|
||||
setConfirmPassphrase('')
|
||||
}
|
||||
}}>
|
||||
<Space direction="vertical">
|
||||
<Radio value="without_secrets">{t('backup.options.without_secrets')}</Radio>
|
||||
<Radio value="with_secrets">{t('backup.options.with_secrets')}</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{includeSecrets && (
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('backup.options.secrets_warning.title')}
|
||||
description={t('backup.options.secrets_warning.description')}
|
||||
/>
|
||||
<Checkbox checked={secretsAcknowledged} onChange={(e) => setSecretsAcknowledged(e.target.checked)}>
|
||||
{t('backup.options.secrets_ack')}
|
||||
</Checkbox>
|
||||
<Checkbox checked={encryptBackup} onChange={(e) => setEncryptBackup(e.target.checked)}>
|
||||
{t('backup.options.encrypt_with_passphrase')}
|
||||
</Checkbox>
|
||||
|
||||
{encryptBackup && (
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Input.Password
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder={t('backup.options.passphrase')}
|
||||
/>
|
||||
<Input.Password
|
||||
value={confirmPassphrase}
|
||||
onChange={(e) => setConfirmPassphrase(e.target.value)}
|
||||
placeholder={t('backup.options.passphrase_confirm')}
|
||||
status={confirmPassphrase.length > 0 && !passphraseValid ? 'error' : undefined}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Typography.Text type="secondary">{t('backup.options.path_tip')}</Typography.Text>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
{progressData && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<Progress percent={Math.floor(progressData.progress)} strokeColor="var(--color-primary)" />
|
||||
|
||||
@ -34,7 +34,19 @@ const resources = Object.fromEntries(
|
||||
)
|
||||
|
||||
export const getLanguage = () => {
|
||||
return localStorage.getItem('language') || navigator.language || defaultLanguage
|
||||
let savedLanguage: string | null = null
|
||||
try {
|
||||
savedLanguage =
|
||||
typeof window !== 'undefined' && typeof window.localStorage?.getItem === 'function'
|
||||
? window.localStorage.getItem('language')
|
||||
: null
|
||||
} catch {
|
||||
savedLanguage = null
|
||||
}
|
||||
|
||||
const browserLanguage = typeof navigator !== 'undefined' ? navigator.language : undefined
|
||||
|
||||
return savedLanguage || browserLanguage || defaultLanguage
|
||||
}
|
||||
|
||||
export const getLanguageCode = () => {
|
||||
|
||||
@ -108,6 +108,7 @@ const backupProgressKeyMap = {
|
||||
completed: 'backup.progress.completed',
|
||||
compressing: 'backup.progress.compressing',
|
||||
copying_files: 'backup.progress.copying_files',
|
||||
encrypting: 'backup.progress.encrypting',
|
||||
preparing_compression: 'backup.progress.preparing_compression',
|
||||
preparing: 'backup.progress.preparing',
|
||||
title: 'backup.progress.title',
|
||||
@ -121,6 +122,7 @@ export const getBackupProgressLabel = (key: string): string => {
|
||||
const restoreProgressKeyMap = {
|
||||
completed: 'restore.progress.completed',
|
||||
copying_files: 'restore.progress.copying_files',
|
||||
decrypting: 'restore.progress.decrypting',
|
||||
extracted: 'restore.progress.extracted',
|
||||
extracting: 'restore.progress.extracting',
|
||||
preparing: 'restore.progress.preparing',
|
||||
|
||||
@ -610,11 +610,28 @@
|
||||
"button": "Select Backup Location",
|
||||
"label": "Are you sure you want to backup data?"
|
||||
},
|
||||
"content": "Backup all data, including chat history, settings, and knowledge base. Please note that the backup process may take some time, thank you for your patience.",
|
||||
"content": "Backup app data, including chat history, settings, and knowledge base. By default, credentials are excluded. Please note that the backup process may take some time, thank you for your patience.",
|
||||
"options": {
|
||||
"encrypt_with_passphrase": "Encrypt backup with a passphrase (recommended)",
|
||||
"passphrase": "Passphrase",
|
||||
"passphrase_confirm": "Confirm passphrase",
|
||||
"path_tip": "Tip: Avoid saving backups containing credentials to public or cloud-synced folders.",
|
||||
"secrets_ack": "I understand the backup includes plaintext credentials.",
|
||||
"secrets_warning": {
|
||||
"description": "Including credentials exports them in a portable form. Store the backup securely and avoid public or cloud-synced folders.",
|
||||
"title": "This backup includes credentials"
|
||||
},
|
||||
"with_secrets": "Include credentials (portable across devices)",
|
||||
"without_secrets": "Default (without credentials)"
|
||||
},
|
||||
"path": {
|
||||
"warning_public_dir": "You selected a potentially unsafe folder (public or cloud-synced). For backups containing credentials, avoid saving to public or cloud-synced folders. Continue?"
|
||||
},
|
||||
"progress": {
|
||||
"completed": "Backup completed",
|
||||
"compressing": "Compressing files...",
|
||||
"copying_files": "Copying files... {{progress}}%",
|
||||
"encrypting": "Encrypting backup...",
|
||||
"preparing": "Preparing backup...",
|
||||
"preparing_compression": "Preparing compression...",
|
||||
"title": "Backup Progress",
|
||||
@ -1292,7 +1309,8 @@
|
||||
"availableProviders": "Available Providers",
|
||||
"availableTools": "Available Tools",
|
||||
"backup": {
|
||||
"file_format": "Backup file format error"
|
||||
"file_format": "Backup file format error",
|
||||
"invalid_passphrase": "Invalid backup passphrase"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
@ -1954,6 +1972,7 @@
|
||||
},
|
||||
"restore": {
|
||||
"failed": "Restore failed",
|
||||
"secrets_migration_notice": "Some credentials may need to be re-authorized on this device",
|
||||
"success": "Restored successfully"
|
||||
},
|
||||
"save": {
|
||||
@ -2680,9 +2699,16 @@
|
||||
"label": "Are you sure you want to restore data?"
|
||||
},
|
||||
"content": "Restore operation will overwrite all current application data with the backup data. Please note that the restore process may take some time, thank you for your patience.",
|
||||
"passphrase": {
|
||||
"description": "Enter the passphrase to decrypt this backup.",
|
||||
"empty": "Passphrase is required",
|
||||
"placeholder": "Passphrase",
|
||||
"title": "Backup Passphrase"
|
||||
},
|
||||
"progress": {
|
||||
"completed": "Restore completed",
|
||||
"copying_files": "Copying files... {{progress}}%",
|
||||
"decrypting": "Decrypting backup...",
|
||||
"extracted": "Extraction successful",
|
||||
"extracting": "Extracting backup...",
|
||||
"preparing": "Preparing restore...",
|
||||
@ -4645,6 +4671,18 @@
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Please enter phrase title"
|
||||
},
|
||||
"security": {
|
||||
"credentials_invalid": {
|
||||
"action_data": "Go to Data settings",
|
||||
"action_docprocess": "Go to Document processing",
|
||||
"action_provider": "Go to Providers",
|
||||
"action_websearch": "Go to Web search",
|
||||
"affected": "Affected credentials: {{items}}",
|
||||
"description": "Some credentials could not be decrypted or were lost (e.g. after reinstall or OS keychain changes). Please re-enter API keys or re-authorize.",
|
||||
"dismiss": "Dismiss",
|
||||
"title": "Credentials need re-authorization"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "Action",
|
||||
"actions": "operation",
|
||||
|
||||
@ -610,11 +610,28 @@
|
||||
"button": "选择备份位置",
|
||||
"label": "确定要备份数据吗?"
|
||||
},
|
||||
"content": "备份全部数据,包括聊天记录、设置、知识库等所有数据。请注意,备份过程可能需要一些时间,感谢您的耐心等待",
|
||||
"content": "备份应用数据,包括聊天记录、设置、知识库等。默认不包含凭据(secrets)。请注意,备份过程可能需要一些时间,感谢您的耐心等待",
|
||||
"options": {
|
||||
"encrypt_with_passphrase": "使用口令加密备份文件(推荐)",
|
||||
"passphrase": "口令",
|
||||
"passphrase_confirm": "确认口令",
|
||||
"path_tip": "提示:包含凭据的备份请避免保存到公共目录或网盘同步目录。",
|
||||
"secrets_ack": "我理解备份包含明文凭据",
|
||||
"secrets_warning": {
|
||||
"description": "包含凭据会以可迁移形式导出(解密后的凭据)。请妥善保管备份文件,避免放在公共目录或网盘同步目录。",
|
||||
"title": "此备份将包含凭据"
|
||||
},
|
||||
"with_secrets": "包含凭据(可跨设备迁移)",
|
||||
"without_secrets": "默认(不包含凭据)"
|
||||
},
|
||||
"path": {
|
||||
"warning_public_dir": "你选择了可能不安全的目录(公共目录/网盘同步目录)。对于包含凭据的备份,请避免保存到公共目录或网盘同步目录。仍要继续吗?"
|
||||
},
|
||||
"progress": {
|
||||
"completed": "备份完成",
|
||||
"compressing": "压缩文件...",
|
||||
"copying_files": "复制文件... {{progress}}%",
|
||||
"encrypting": "加密备份...",
|
||||
"preparing": "准备备份...",
|
||||
"preparing_compression": "准备压缩...",
|
||||
"title": "备份进度",
|
||||
@ -1292,7 +1309,8 @@
|
||||
"availableProviders": "可用提供商",
|
||||
"availableTools": "可用工具",
|
||||
"backup": {
|
||||
"file_format": "备份文件格式错误"
|
||||
"file_format": "备份文件格式错误",
|
||||
"invalid_passphrase": "备份口令错误"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
@ -1954,6 +1972,7 @@
|
||||
},
|
||||
"restore": {
|
||||
"failed": "恢复失败",
|
||||
"secrets_migration_notice": "部分凭据可能无法迁移到当前设备,请按需重新授权",
|
||||
"success": "恢复成功"
|
||||
},
|
||||
"save": {
|
||||
@ -2680,9 +2699,16 @@
|
||||
"label": "确定要恢复数据吗?"
|
||||
},
|
||||
"content": "恢复操作将使用备份数据覆盖当前所有应用数据。请注意,恢复过程可能需要一些时间,感谢您的耐心等待",
|
||||
"passphrase": {
|
||||
"description": "该备份已加密,请输入口令解密。",
|
||||
"empty": "口令不能为空",
|
||||
"placeholder": "口令",
|
||||
"title": "输入备份口令"
|
||||
},
|
||||
"progress": {
|
||||
"completed": "恢复完成",
|
||||
"copying_files": "复制文件... {{progress}}%",
|
||||
"decrypting": "解密备份...",
|
||||
"extracted": "解压成功",
|
||||
"extracting": "解压备份...",
|
||||
"preparing": "准备恢复...",
|
||||
@ -4645,6 +4671,18 @@
|
||||
"titleLabel": "标题",
|
||||
"titlePlaceholder": "请输入短语标题"
|
||||
},
|
||||
"security": {
|
||||
"credentials_invalid": {
|
||||
"action_data": "前往数据设置",
|
||||
"action_docprocess": "前往文档处理设置",
|
||||
"action_provider": "前往服务商设置",
|
||||
"action_websearch": "前往网页搜索设置",
|
||||
"affected": "受影响的凭据:{{items}}",
|
||||
"description": "检测到部分凭据无法解密或已丢失(可能由于卸载重装、系统钥匙串变化等)。请到对应设置重新输入 API Key 或重新授权。",
|
||||
"dismiss": "忽略",
|
||||
"title": "凭据失效,需要重新授权"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "操作",
|
||||
"actions": "操作",
|
||||
|
||||
@ -610,11 +610,28 @@
|
||||
"button": "選擇備份位置",
|
||||
"label": "確定要備份資料嗎?"
|
||||
},
|
||||
"content": "備份全部資料,包括聊天記錄、設定、知識庫等全部資料。請注意,備份過程可能需要一些時間,感謝您的耐心等待",
|
||||
"content": "備份應用資料,包括聊天記錄、設定、知識庫等。預設不包含憑證(secrets)。請注意,備份過程可能需要一些時間,感謝您的耐心等待",
|
||||
"options": {
|
||||
"encrypt_with_passphrase": "使用口令加密備份檔案(推薦)",
|
||||
"passphrase": "口令",
|
||||
"passphrase_confirm": "確認口令",
|
||||
"path_tip": "提示:包含憑證的備份請避免保存到公開目錄或雲端同步目錄。",
|
||||
"secrets_ack": "我理解備份包含明文憑證",
|
||||
"secrets_warning": {
|
||||
"description": "包含憑證會以可遷移形式匯出(解密後的憑證)。請妥善保管備份檔案,避免放在公開目錄或雲端同步目錄。",
|
||||
"title": "此備份將包含憑證"
|
||||
},
|
||||
"with_secrets": "包含憑證(可跨裝置遷移)",
|
||||
"without_secrets": "預設(不包含憑證)"
|
||||
},
|
||||
"path": {
|
||||
"warning_public_dir": "你選擇了可能不安全的目錄(公開目錄/雲端同步目錄)。對於包含憑證的備份,請避免保存到公開目錄或雲端同步目錄。仍要繼續嗎?"
|
||||
},
|
||||
"progress": {
|
||||
"completed": "備份完成",
|
||||
"compressing": "壓縮檔案...",
|
||||
"copying_files": "複製檔案... {{progress}}%",
|
||||
"encrypting": "加密備份...",
|
||||
"preparing": "準備備份...",
|
||||
"preparing_compression": "準備壓縮...",
|
||||
"title": "備份進度",
|
||||
@ -1292,7 +1309,8 @@
|
||||
"availableProviders": "可用供應商",
|
||||
"availableTools": "可用工具",
|
||||
"backup": {
|
||||
"file_format": "備份檔案格式錯誤"
|
||||
"file_format": "備份檔案格式錯誤",
|
||||
"invalid_passphrase": "備份口令錯誤"
|
||||
},
|
||||
"boundary": {
|
||||
"default": {
|
||||
@ -1954,6 +1972,7 @@
|
||||
},
|
||||
"restore": {
|
||||
"failed": "還原失敗",
|
||||
"secrets_migration_notice": "部分憑證可能無法遷移到目前裝置,請視需要重新授權",
|
||||
"success": "還原成功"
|
||||
},
|
||||
"save": {
|
||||
@ -2680,9 +2699,16 @@
|
||||
"label": "確定要復原資料嗎?"
|
||||
},
|
||||
"content": "復原操作將使用備份資料覆蓋目前所有應用程式資料。請注意,復原過程可能需要一些時間,感謝您的耐心等待",
|
||||
"passphrase": {
|
||||
"description": "該備份已加密,請輸入口令解密。",
|
||||
"empty": "口令不能為空",
|
||||
"placeholder": "口令",
|
||||
"title": "輸入備份口令"
|
||||
},
|
||||
"progress": {
|
||||
"completed": "復原完成",
|
||||
"copying_files": "複製檔案... {{progress}}%",
|
||||
"decrypting": "解密備份...",
|
||||
"extracted": "解壓成功",
|
||||
"extracting": "解開備份...",
|
||||
"preparing": "準備復原...",
|
||||
@ -4645,6 +4671,18 @@
|
||||
"titleLabel": "標題",
|
||||
"titlePlaceholder": "請輸入短語標題"
|
||||
},
|
||||
"security": {
|
||||
"credentials_invalid": {
|
||||
"action_data": "前往資料設定",
|
||||
"action_docprocess": "前往文件處理設定",
|
||||
"action_provider": "前往服務商設定",
|
||||
"action_websearch": "前往網頁搜尋設定",
|
||||
"affected": "受影響的憑證:{{items}}",
|
||||
"description": "偵測到部分憑證無法解密或已遺失(可能因為卸載重裝、系統鑰匙圈變更等)。請到對應設定重新輸入 API Key 或重新授權。",
|
||||
"dismiss": "忽略",
|
||||
"title": "憑證失效,需要重新授權"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "操作",
|
||||
"actions": "操作",
|
||||
|
||||
130
src/renderer/src/pages/settings/CredentialIssuesBanner.tsx
Normal file
130
src/renderer/src/pages/settings/CredentialIssuesBanner.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { Alert, Button, Space } from 'antd'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../store'
|
||||
import { clearCredentialIssues } from '../../store/runtime'
|
||||
|
||||
const CredentialIssuesBanner = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const issues = useAppSelector((state) => state.runtime.credentialIssues)
|
||||
const llmProviders = useAppSelector((state) => state.llm.providers)
|
||||
const preprocessProviders = useAppSelector((state) => state.preprocess.providers)
|
||||
const webSearchProviders = useAppSelector((state) => state.websearch.providers)
|
||||
|
||||
const affectedNames = useMemo(() => {
|
||||
const names = new Set<string>()
|
||||
for (const issue of issues) {
|
||||
const scope = issue.meta?.scope
|
||||
const providerId = issue.meta?.providerId
|
||||
if (typeof providerId !== 'string') continue
|
||||
|
||||
if (scope === 'llm') {
|
||||
const provider = llmProviders?.find((p: any) => p?.id === providerId)
|
||||
names.add(provider?.name || providerId)
|
||||
continue
|
||||
}
|
||||
|
||||
if (scope === 'preprocess') {
|
||||
const provider = preprocessProviders?.find((p: any) => p?.id === providerId)
|
||||
names.add(provider?.name || providerId)
|
||||
continue
|
||||
}
|
||||
|
||||
if (scope === 'websearch') {
|
||||
const provider = webSearchProviders?.find((p: any) => p?.id === providerId)
|
||||
names.add(provider?.name || providerId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(names)
|
||||
}, [issues, llmProviders, preprocessProviders, webSearchProviders])
|
||||
|
||||
const affectedPreview = useMemo(() => {
|
||||
if (affectedNames.length === 0) return null
|
||||
const preview = affectedNames.slice(0, 5).join(', ')
|
||||
return affectedNames.length > 5 ? `${preview}…` : preview
|
||||
}, [affectedNames])
|
||||
|
||||
const showProviderSettings = issues.some((issue) => issue.meta?.scope === 'llm')
|
||||
const showWebSearchSettings = issues.some((issue) => issue.meta?.scope === 'websearch')
|
||||
const showDocProcessSettings = issues.some((issue) => issue.meta?.scope === 'preprocess')
|
||||
const showDataSettings = issues.some(
|
||||
(issue) =>
|
||||
issue.meta?.scope === 'settings' ||
|
||||
issue.meta?.scope === 'nutstore' ||
|
||||
(typeof issue.id === 'string' && issue.id.startsWith('localStorage.'))
|
||||
)
|
||||
|
||||
if (!issues.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<Space direction="vertical">
|
||||
{showProviderSettings && (
|
||||
<Button size="small" type="primary" onClick={() => navigate('/settings/provider')}>
|
||||
{t('settings.security.credentials_invalid.action_provider')}
|
||||
</Button>
|
||||
)}
|
||||
{showWebSearchSettings && (
|
||||
<Button size="small" onClick={() => navigate('/settings/websearch')}>
|
||||
{t('settings.security.credentials_invalid.action_websearch')}
|
||||
</Button>
|
||||
)}
|
||||
{showDocProcessSettings && (
|
||||
<Button size="small" onClick={() => navigate('/settings/docprocess')}>
|
||||
{t('settings.security.credentials_invalid.action_docprocess')}
|
||||
</Button>
|
||||
)}
|
||||
{showDataSettings && (
|
||||
<Button size="small" onClick={() => navigate('/settings/data')}>
|
||||
{t('settings.security.credentials_invalid.action_data')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" onClick={() => dispatch(clearCredentialIssues())}>
|
||||
{t('settings.security.credentials_invalid.dismiss')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('settings.security.credentials_invalid.title')}
|
||||
description={
|
||||
<Description>
|
||||
<div>{t('settings.security.credentials_invalid.description')}</div>
|
||||
{affectedPreview ? (
|
||||
<Affected>{t('settings.security.credentials_invalid.affected', { items: affectedPreview })}</Affected>
|
||||
) : null}
|
||||
</Description>
|
||||
}
|
||||
action={actions}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 10px;
|
||||
`
|
||||
|
||||
const Description = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const Affected = styled.div`
|
||||
opacity: 0.9;
|
||||
`
|
||||
|
||||
export default CredentialIssuesBanner
|
||||
@ -1,6 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('302ai')
|
||||
@ -10,11 +11,11 @@ const TOKEN_STORAGE_KEY = 'ai302_token'
|
||||
export const AI302_HOST = 'https://api.302.ai/mcp'
|
||||
|
||||
export const saveAI302Token = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getAI302Token = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearAI302Token = (): void => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('BailianSyncUtils')
|
||||
@ -11,12 +12,11 @@ const TOKEN_STORAGE_KEY = 'bailian_token'
|
||||
|
||||
// Token 工具函数
|
||||
export const saveBailianToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getBailianToken = (): string | null => {
|
||||
const token = localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return token
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearBailianToken = (): void => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('TokenLanYunSyncUtils')
|
||||
@ -11,11 +12,11 @@ export const LANYUN_MCP_HOST = TOKENLANYUN_HOST + '/mcp/manager/selectListByApiK
|
||||
export const LANYUN_KEY_HOST = TOKENLANYUN_HOST + '/#/manage/apiKey'
|
||||
|
||||
export const saveTokenLanYunToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getTokenLanYunToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearTokenLanYunToken = (): void => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('MCPRouterSyncUtils')
|
||||
@ -10,11 +11,11 @@ const TOKEN_STORAGE_KEY = 'mcprouter_token'
|
||||
export const MCPROUTER_HOST = 'https://mcprouter.co'
|
||||
|
||||
export const saveMCPRouterToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getMCPRouterToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearMCPRouterToken = (): void => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { getMcpServerType, type MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('ModelScopeSyncUtils')
|
||||
@ -10,11 +11,11 @@ const TOKEN_STORAGE_KEY = 'modelscope_token'
|
||||
export const MODELSCOPE_HOST = 'https://www.modelscope.cn'
|
||||
|
||||
export const saveModelScopeToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getModelScopeToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearModelScopeToken = (): void => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('TokenFluxSyncUtils')
|
||||
@ -10,11 +11,11 @@ const TOKEN_STORAGE_KEY = 'tokenflux_token'
|
||||
export const TOKENFLUX_HOST = 'https://tokenflux.ai'
|
||||
|
||||
export const saveTokenFluxToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getTokenFluxToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearTokenFluxToken = (): void => {
|
||||
|
||||
@ -26,6 +26,7 @@ import { Link, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import AboutSettings from './AboutSettings'
|
||||
import CredentialIssuesBanner from './CredentialIssuesBanner'
|
||||
import DataSettings from './DataSettings/DataSettings'
|
||||
import DisplaySettings from './DisplaySettings/DisplaySettings'
|
||||
import DocProcessSettings from './DocProcessSettings'
|
||||
@ -156,24 +157,27 @@ const SettingsPage: FC = () => {
|
||||
</MenuItemLink>
|
||||
</SettingMenus>
|
||||
<SettingContent>
|
||||
<Routes>
|
||||
<Route path="provider" element={<ProviderList />} />
|
||||
<Route path="model" element={<ModelSettings />} />
|
||||
<Route path="websearch" element={<WebSearchSettings />} />
|
||||
<Route path="api-server" element={<ApiServerSettings />} />
|
||||
<Route path="docprocess" element={<DocProcessSettings />} />
|
||||
<Route path="quickphrase" element={<QuickPhraseSettings />} />
|
||||
<Route path="mcp/*" element={<MCPSettings />} />
|
||||
<Route path="memory" element={<MemorySettings />} />
|
||||
<Route path="general/*" element={<GeneralSettings />} />
|
||||
<Route path="display" element={<DisplaySettings />} />
|
||||
<Route path="shortcut" element={<ShortcutSettings />} />
|
||||
<Route path="quickAssistant" element={<QuickAssistantSettings />} />
|
||||
<Route path="selectionAssistant" element={<SelectionAssistantSettings />} />
|
||||
<Route path="data" element={<DataSettings />} />
|
||||
<Route path="notes" element={<NotesSettings />} />
|
||||
<Route path="about" element={<AboutSettings />} />
|
||||
</Routes>
|
||||
<CredentialIssuesBanner />
|
||||
<RoutesContainer>
|
||||
<Routes>
|
||||
<Route path="provider" element={<ProviderList />} />
|
||||
<Route path="model" element={<ModelSettings />} />
|
||||
<Route path="websearch" element={<WebSearchSettings />} />
|
||||
<Route path="api-server" element={<ApiServerSettings />} />
|
||||
<Route path="docprocess" element={<DocProcessSettings />} />
|
||||
<Route path="quickphrase" element={<QuickPhraseSettings />} />
|
||||
<Route path="mcp/*" element={<MCPSettings />} />
|
||||
<Route path="memory" element={<MemorySettings />} />
|
||||
<Route path="general/*" element={<GeneralSettings />} />
|
||||
<Route path="display" element={<DisplaySettings />} />
|
||||
<Route path="shortcut" element={<ShortcutSettings />} />
|
||||
<Route path="quickAssistant" element={<QuickAssistantSettings />} />
|
||||
<Route path="selectionAssistant" element={<SelectionAssistantSettings />} />
|
||||
<Route path="data" element={<DataSettings />} />
|
||||
<Route path="notes" element={<NotesSettings />} />
|
||||
<Route path="about" element={<AboutSettings />} />
|
||||
</Routes>
|
||||
</RoutesContainer>
|
||||
</SettingContent>
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
@ -236,10 +240,17 @@ const MenuItem = styled.li`
|
||||
|
||||
const SettingContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const RoutesContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const Divider = styled(AntDivider)`
|
||||
margin: 3px 0;
|
||||
`
|
||||
|
||||
@ -6,7 +6,10 @@ import store from '@renderer/store'
|
||||
import { setLocalBackupSyncState, setS3SyncState, setWebDAVSyncState } from '@renderer/store/backup'
|
||||
import type { S3Config, WebDavConfig } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { stripPersistedRootStateSecretsString, transformPersistedRootStateString } from '@renderer/utils/securePersist'
|
||||
import { Input } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { createElement } from 'react'
|
||||
|
||||
import { NotificationService } from './NotificationService'
|
||||
|
||||
@ -63,26 +66,92 @@ async function deleteWebdavFileWithRetry(fileName: string, webdavConfig: WebDavC
|
||||
}
|
||||
|
||||
export async function backup(skipBackupFile: boolean) {
|
||||
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip`
|
||||
const fileContnet = await getBackupData()
|
||||
const selectFolder = await window.api.file.selectFolder()
|
||||
if (selectFolder) {
|
||||
await window.api.backup.backup(filename, fileContnet, selectFolder, skipBackupFile)
|
||||
window.toast.success(i18n.t('message.backup.success'))
|
||||
return backupWithOptions(skipBackupFile)
|
||||
}
|
||||
|
||||
export type BackupExportOptions = {
|
||||
includeSecrets?: boolean
|
||||
passphrase?: string
|
||||
}
|
||||
|
||||
export async function backupWithOptions(skipBackupFile: boolean, options: BackupExportOptions = {}): Promise<boolean> {
|
||||
const includeSecrets = Boolean(options.includeSecrets)
|
||||
const passphrase =
|
||||
typeof options.passphrase === 'string' && options.passphrase.length > 0 ? options.passphrase : undefined
|
||||
|
||||
const extension = passphrase ? 'csbackup' : 'zip'
|
||||
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.${extension}`
|
||||
const fileContnet = await getBackupData({ includeSecrets })
|
||||
|
||||
const appInfo = await window.api.getAppInfo()
|
||||
const defaultPath = appInfo?.documentsPath || appInfo?.appDataPath
|
||||
|
||||
const selectFolder = await window.api.file.selectFolder(defaultPath ? { defaultPath } : undefined)
|
||||
if (!selectFolder) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (includeSecrets) {
|
||||
const downloadsPath = appInfo?.downloadsPath
|
||||
const desktopPath = appInfo?.desktopPath
|
||||
const isDownloads = downloadsPath ? await window.api.isPathInside(selectFolder, downloadsPath) : false
|
||||
const isDesktop = desktopPath ? await window.api.isPathInside(selectFolder, desktopPath) : false
|
||||
const isCloudSynced = isPossiblyCloudSyncedPath(selectFolder)
|
||||
|
||||
if (isDownloads || isDesktop || isCloudSynced) {
|
||||
const confirmed = await window.modal.confirm({
|
||||
title: i18n.t('common.warning'),
|
||||
content: i18n.t('backup.path.warning_public_dir'),
|
||||
centered: true
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await window.api.backup.backup(filename, fileContnet, selectFolder, skipBackupFile, { passphrase })
|
||||
window.toast.success(i18n.t('message.backup.success'))
|
||||
return true
|
||||
}
|
||||
|
||||
const isPossiblyCloudSyncedPath = (folderPath: string): boolean => {
|
||||
const normalized = folderPath.replaceAll('\\', '/').toLowerCase().replaceAll(' ', '')
|
||||
const hints = [
|
||||
'onedrive',
|
||||
'dropbox',
|
||||
'googledrive',
|
||||
'google-drive',
|
||||
'icloud',
|
||||
'icloudrive',
|
||||
'box',
|
||||
'mega',
|
||||
'syncthing'
|
||||
]
|
||||
|
||||
return hints.some((hint) => normalized.includes(hint))
|
||||
}
|
||||
|
||||
export async function restore() {
|
||||
const notificationService = NotificationService.getInstance()
|
||||
const file = await window.api.file.open({ filters: [{ name: '备份文件', extensions: ['bak', 'zip'] }] })
|
||||
const file = await window.api.file.open({ filters: [{ name: '备份文件', extensions: ['bak', 'zip', 'csbackup'] }] })
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
let data: Record<string, any> = {}
|
||||
|
||||
// zip backup file
|
||||
if (file?.fileName.endsWith('.zip')) {
|
||||
const restoreData = await window.api.backup.restore(file.filePath)
|
||||
if (file?.fileName.endsWith('.zip') || file?.fileName.endsWith('.csbackup')) {
|
||||
let passphrase: string | undefined
|
||||
if (file.fileName.endsWith('.csbackup')) {
|
||||
passphrase = await promptBackupPassphrase()
|
||||
if (!passphrase) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const restoreData = await window.api.backup.restore(file.filePath, passphrase ? { passphrase } : undefined)
|
||||
data = JSON.parse(restoreData)
|
||||
} else {
|
||||
data = JSON.parse(await window.api.zip.decompress(file.content))
|
||||
@ -102,11 +171,45 @@ export async function restore() {
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('restore: Error restoring backup file:', error as Error)
|
||||
window.toast.error(i18n.t('error.backup.file_format'))
|
||||
const message = (error as Error)?.message || ''
|
||||
if (message.toLowerCase().includes('passphrase')) {
|
||||
window.toast.error(i18n.t('error.backup.invalid_passphrase'))
|
||||
} else {
|
||||
window.toast.error(i18n.t('error.backup.file_format'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function promptBackupPassphrase(): Promise<string | undefined> {
|
||||
let passphrase = ''
|
||||
|
||||
const confirmed = await window.modal.confirm({
|
||||
title: i18n.t('restore.passphrase.title'),
|
||||
content: createElement('div', null, [
|
||||
createElement('div', { key: 'desc', style: { marginBottom: 8 } }, i18n.t('restore.passphrase.description')),
|
||||
createElement(Input.Password, {
|
||||
key: 'input',
|
||||
placeholder: i18n.t('restore.passphrase.placeholder'),
|
||||
autoFocus: true,
|
||||
autoComplete: 'new-password',
|
||||
onChange: (event: any) => {
|
||||
passphrase = event?.target?.value ?? ''
|
||||
}
|
||||
})
|
||||
]),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
if (!passphrase) {
|
||||
window.toast.error(i18n.t('restore.passphrase.empty'))
|
||||
throw new Error('Passphrase required')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return confirmed ? passphrase : undefined
|
||||
}
|
||||
|
||||
export async function reset() {
|
||||
window.modal.confirm({
|
||||
title: i18n.t('common.warning'),
|
||||
@ -820,11 +923,31 @@ export function stopAutoSync(type?: BackupType) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBackupData() {
|
||||
export async function getBackupData(options: { includeSecrets?: boolean } = {}) {
|
||||
const localStorageSnapshot: Record<string, string> = {}
|
||||
for (let index = 0; index < localStorage.length; index++) {
|
||||
const key = localStorage.key(index)
|
||||
if (!key) {
|
||||
continue
|
||||
}
|
||||
const value = localStorage.getItem(key)
|
||||
if (value !== null) {
|
||||
localStorageSnapshot[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const persistedKey = 'persist:cherry-studio'
|
||||
if (typeof localStorageSnapshot[persistedKey] === 'string') {
|
||||
localStorageSnapshot[persistedKey] = options.includeSecrets
|
||||
? transformPersistedRootStateString(localStorageSnapshot[persistedKey], 'decrypt')
|
||||
: stripPersistedRootStateSecretsString(localStorageSnapshot[persistedKey])
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
time: new Date().getTime(),
|
||||
version: 5,
|
||||
localStorage,
|
||||
version: 6,
|
||||
includesSecrets: Boolean(options.includeSecrets),
|
||||
localStorage: localStorageSnapshot,
|
||||
indexedDB: await backupDatabase()
|
||||
})
|
||||
}
|
||||
@ -850,7 +973,17 @@ export async function handleData(data: Record<string, any>) {
|
||||
}
|
||||
|
||||
if (data.version >= 2) {
|
||||
localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio'])
|
||||
const persistedKey = 'persist:cherry-studio'
|
||||
const persistedValue = data?.localStorage?.[persistedKey]
|
||||
if (typeof persistedValue === 'string') {
|
||||
const decrypted = transformPersistedRootStateString(persistedValue, 'decrypt')
|
||||
const reencrypted = transformPersistedRootStateString(decrypted, 'encrypt')
|
||||
localStorage.setItem(persistedKey, reencrypted)
|
||||
|
||||
if (data.version < 6 && persistedValue.includes('csenc:')) {
|
||||
window.toast.warning(i18n.t('message.restore.secrets_migration_notice'))
|
||||
}
|
||||
}
|
||||
|
||||
// remove notes_tree from indexedDB
|
||||
if (data.indexedDB['notes_tree']) {
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit'
|
||||
import { useDispatch, useSelector, useStore } from 'react-redux'
|
||||
import { FLUSH, PAUSE, PERSIST, persistReducer, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist'
|
||||
import {
|
||||
createTransform,
|
||||
FLUSH,
|
||||
PAUSE,
|
||||
PERSIST,
|
||||
persistReducer,
|
||||
persistStore,
|
||||
PURGE,
|
||||
REGISTER,
|
||||
REHYDRATE
|
||||
} from 'redux-persist'
|
||||
import storage from 'redux-persist/lib/storage'
|
||||
|
||||
import storeSyncService from '../services/StoreSyncService'
|
||||
import { decryptPersistSliceState, encryptPersistSliceState, SECURE_PERSIST_SLICE_KEYS } from '../utils/securePersist'
|
||||
import type { CredentialIssue } from '../utils/secureStorage'
|
||||
import { consumeCredentialIssues, CREDENTIAL_ISSUE_EVENT_NAME } from '../utils/secureStorage'
|
||||
import assistants from './assistants'
|
||||
import backup from './backup'
|
||||
import codeTools from './codeTools'
|
||||
@ -24,7 +37,7 @@ import nutstore from './nutstore'
|
||||
import ocr from './ocr'
|
||||
import paintings from './paintings'
|
||||
import preprocess from './preprocess'
|
||||
import runtime from './runtime'
|
||||
import runtime, { addCredentialIssue, setCredentialIssues } from './runtime'
|
||||
import selectionStore from './selectionStore'
|
||||
import settings from './settings'
|
||||
import shortcuts from './shortcuts'
|
||||
@ -34,6 +47,15 @@ import translate from './translate'
|
||||
import websearch from './websearch'
|
||||
|
||||
const logger = loggerService.withContext('Store')
|
||||
let credentialIssueListenerAttached = false
|
||||
|
||||
const securePersistTransform = createTransform(
|
||||
(inboundState: any, key) => encryptPersistSliceState(String(key), inboundState),
|
||||
(outboundState: any, key) => decryptPersistSliceState(String(key), outboundState),
|
||||
{
|
||||
whitelist: [...SECURE_PERSIST_SLICE_KEYS]
|
||||
}
|
||||
)
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
assistants,
|
||||
@ -69,9 +91,10 @@ const persistedReducer = persistReducer(
|
||||
storage,
|
||||
version: 187,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
|
||||
transforms: [securePersistTransform],
|
||||
migrate
|
||||
},
|
||||
rootReducer
|
||||
rootReducer as any
|
||||
)
|
||||
|
||||
/**
|
||||
@ -120,6 +143,36 @@ export const persistor = persistStore(store, undefined, () => {
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// Proactively flush once after rehydration so secrets are re-persisted in encrypted form.
|
||||
// This is best-effort and should never block app startup.
|
||||
const pathname = window.location?.pathname || ''
|
||||
const isMainWindow = pathname === '/' || pathname.endsWith('/index.html') || pathname.endsWith('index.html')
|
||||
if (isMainWindow && window.api?.safeStorage?.isEncryptionAvailable?.()) {
|
||||
setTimeout(() => {
|
||||
persistor.flush().catch(() => {})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const issues = consumeCredentialIssues()
|
||||
if (issues.length > 0) {
|
||||
store.dispatch(setCredentialIssues(issues))
|
||||
}
|
||||
|
||||
if (
|
||||
!credentialIssueListenerAttached &&
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.addEventListener === 'function'
|
||||
) {
|
||||
credentialIssueListenerAttached = true
|
||||
window.addEventListener(CREDENTIAL_ISSUE_EVENT_NAME, ((event: Event) => {
|
||||
const issue = (event as CustomEvent).detail as CredentialIssue | undefined
|
||||
if (!issue || typeof issue.id !== 'string' || typeof issue.reason !== 'string') {
|
||||
return
|
||||
}
|
||||
store.dispatch(addCredentialIssue(issue))
|
||||
}) as EventListener)
|
||||
}
|
||||
})
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
||||
|
||||
@ -2,6 +2,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
||||
import type { MinAppType, Topic, WebSearchStatus } from '@renderer/types'
|
||||
import type { CredentialIssue } from '@renderer/utils/secureStorage'
|
||||
import type { UpdateInfo } from 'builder-util-runtime'
|
||||
|
||||
export interface ChatState {
|
||||
@ -39,6 +40,7 @@ export interface UpdateState {
|
||||
|
||||
export interface RuntimeState {
|
||||
avatar: string
|
||||
credentialIssues: CredentialIssue[]
|
||||
generating: boolean
|
||||
translating: boolean
|
||||
translateAbortKey?: string
|
||||
@ -65,6 +67,7 @@ export interface ExportState {
|
||||
|
||||
const initialState: RuntimeState = {
|
||||
avatar: UserAvatar,
|
||||
credentialIssues: [],
|
||||
generating: false,
|
||||
translating: false,
|
||||
minappShow: false,
|
||||
@ -109,6 +112,30 @@ const runtimeSlice = createSlice({
|
||||
setAvatar: (state, action: PayloadAction<string | null>) => {
|
||||
state.avatar = action.payload || AppLogo
|
||||
},
|
||||
addCredentialIssue: (state, action: PayloadAction<CredentialIssue>) => {
|
||||
const key = `${action.payload.reason}:${action.payload.id}`
|
||||
const exists = state.credentialIssues.some((issue) => `${issue.reason}:${issue.id}` === key)
|
||||
if (!exists) {
|
||||
state.credentialIssues.push(action.payload)
|
||||
}
|
||||
},
|
||||
clearCredentialIssues: (state) => {
|
||||
state.credentialIssues = []
|
||||
},
|
||||
dismissCredentialIssue: (state, action: PayloadAction<string>) => {
|
||||
state.credentialIssues = state.credentialIssues.filter((issue) => issue.id !== action.payload)
|
||||
},
|
||||
setCredentialIssues: (state, action: PayloadAction<CredentialIssue[]>) => {
|
||||
const nextIssues: CredentialIssue[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const issue of action.payload) {
|
||||
const key = `${issue.reason}:${issue.id}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
nextIssues.push(issue)
|
||||
}
|
||||
state.credentialIssues = nextIssues
|
||||
},
|
||||
setGenerating: (state, action: PayloadAction<boolean>) => {
|
||||
state.generating = action.payload
|
||||
},
|
||||
@ -195,6 +222,10 @@ const runtimeSlice = createSlice({
|
||||
|
||||
export const {
|
||||
setAvatar,
|
||||
addCredentialIssue,
|
||||
clearCredentialIssues,
|
||||
dismissCredentialIssue,
|
||||
setCredentialIssues,
|
||||
setGenerating,
|
||||
setTranslating,
|
||||
setTranslateAbortKey,
|
||||
|
||||
@ -489,6 +489,9 @@ export type AppInfo = {
|
||||
appPath: string
|
||||
configPath: string
|
||||
appDataPath: string
|
||||
documentsPath?: string
|
||||
downloadsPath?: string
|
||||
desktopPath?: string
|
||||
resourcesPath: string
|
||||
filesPath: string
|
||||
logsPath: string
|
||||
|
||||
137
src/renderer/src/utils/__tests__/securePersist.test.ts
Normal file
137
src/renderer/src/utils/__tests__/securePersist.test.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { stripPersistedRootStateSecretsString, transformPersistedRootStateString } from '../securePersist'
|
||||
import { decryptSecret, encryptSecret } from '../secureStorage'
|
||||
|
||||
describe('securePersist', () => {
|
||||
beforeEach(() => {
|
||||
const safeStorage = {
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
encryptString: vi.fn((plainText: string) => {
|
||||
if (plainText.startsWith('csenc:')) return plainText
|
||||
return `csenc:${Buffer.from(plainText, 'utf-8').toString('base64')}`
|
||||
}),
|
||||
decryptString: vi.fn((value: string) => {
|
||||
const prefix = 'csenc:'
|
||||
if (!value.startsWith(prefix)) return value
|
||||
try {
|
||||
return Buffer.from(value.slice(prefix.length), 'base64').toString('utf-8')
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
;(window as any).api = { ...(window as any).api, safeStorage }
|
||||
})
|
||||
|
||||
it('decrypts persisted secrets to plaintext for portability', () => {
|
||||
const persisted = JSON.stringify({
|
||||
llm: JSON.stringify({
|
||||
providers: [{ id: 'p1', apiKey: encryptSecret('k1') }],
|
||||
settings: {
|
||||
awsBedrock: {
|
||||
accessKeyId: encryptSecret('ak'),
|
||||
secretAccessKey: encryptSecret('sk'),
|
||||
apiKey: encryptSecret('bk')
|
||||
},
|
||||
vertexai: {
|
||||
serviceAccount: {
|
||||
privateKey: encryptSecret('pk')
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
settings: JSON.stringify({
|
||||
webdavPass: encryptSecret('pass'),
|
||||
s3: { accessKeyId: encryptSecret('s3ak'), secretAccessKey: encryptSecret('s3sk') },
|
||||
apiServer: { apiKey: encryptSecret('api') }
|
||||
}),
|
||||
preprocess: JSON.stringify({ providers: [{ apiKey: encryptSecret('pre') }] }),
|
||||
websearch: JSON.stringify({ providers: [{ apiKey: encryptSecret('ws') }] }),
|
||||
nutstore: JSON.stringify({ nutstoreToken: encryptSecret('nut') }),
|
||||
_persist: JSON.stringify({ version: 183, rehydrated: true })
|
||||
})
|
||||
|
||||
const decrypted = transformPersistedRootStateString(persisted, 'decrypt')
|
||||
const root = JSON.parse(decrypted) as Record<string, string>
|
||||
|
||||
const llm = JSON.parse(root.llm)
|
||||
expect(llm.providers[0].apiKey).toBe('k1')
|
||||
expect(llm.settings.awsBedrock.accessKeyId).toBe('ak')
|
||||
expect(llm.settings.awsBedrock.secretAccessKey).toBe('sk')
|
||||
expect(llm.settings.awsBedrock.apiKey).toBe('bk')
|
||||
expect(llm.settings.vertexai.serviceAccount.privateKey).toBe('pk')
|
||||
|
||||
const settings = JSON.parse(root.settings)
|
||||
expect(settings.webdavPass).toBe('pass')
|
||||
expect(settings.s3.accessKeyId).toBe('s3ak')
|
||||
expect(settings.s3.secretAccessKey).toBe('s3sk')
|
||||
expect(settings.apiServer.apiKey).toBe('api')
|
||||
|
||||
const preprocess = JSON.parse(root.preprocess)
|
||||
expect(preprocess.providers[0].apiKey).toBe('pre')
|
||||
|
||||
const websearch = JSON.parse(root.websearch)
|
||||
expect(websearch.providers[0].apiKey).toBe('ws')
|
||||
|
||||
const nutstore = JSON.parse(root.nutstore)
|
||||
expect(nutstore.nutstoreToken).toBe('nut')
|
||||
})
|
||||
|
||||
it('re-encrypts plaintext secrets for the current device', () => {
|
||||
const plaintext = JSON.stringify({
|
||||
llm: JSON.stringify({ providers: [{ apiKey: 'k1' }], settings: { awsBedrock: { accessKeyId: 'ak' } } }),
|
||||
settings: JSON.stringify({ webdavPass: 'pass' }),
|
||||
_persist: JSON.stringify({ version: 183, rehydrated: true })
|
||||
})
|
||||
|
||||
const encrypted = transformPersistedRootStateString(plaintext, 'encrypt')
|
||||
const root = JSON.parse(encrypted) as Record<string, string>
|
||||
const llm = JSON.parse(root.llm)
|
||||
const settings = JSON.parse(root.settings)
|
||||
|
||||
expect(typeof llm.providers[0].apiKey).toBe('string')
|
||||
expect(llm.providers[0].apiKey.startsWith('csenc:')).toBe(true)
|
||||
expect(decryptSecret(llm.providers[0].apiKey)).toBe('k1')
|
||||
|
||||
expect(typeof settings.webdavPass).toBe('string')
|
||||
expect(settings.webdavPass.startsWith('csenc:')).toBe(true)
|
||||
expect(decryptSecret(settings.webdavPass)).toBe('pass')
|
||||
})
|
||||
|
||||
it('clears encrypted secrets when decryption fails', () => {
|
||||
;(window as any).api.safeStorage.decryptString = vi.fn((value: string) => value)
|
||||
|
||||
const encrypted = JSON.stringify({
|
||||
settings: JSON.stringify({ webdavPass: encryptSecret('pass') }),
|
||||
_persist: JSON.stringify({ version: 183, rehydrated: true })
|
||||
})
|
||||
|
||||
const decrypted = transformPersistedRootStateString(encrypted, 'decrypt')
|
||||
const root = JSON.parse(decrypted) as Record<string, string>
|
||||
const settings = JSON.parse(root.settings)
|
||||
expect(settings.webdavPass).toBe('')
|
||||
})
|
||||
|
||||
it('strips secrets without requiring decryption', () => {
|
||||
const persisted = JSON.stringify({
|
||||
llm: JSON.stringify({
|
||||
providers: [{ id: 'p1', apiKey: encryptSecret('k1') }],
|
||||
settings: { awsBedrock: { secretAccessKey: encryptSecret('sk') } }
|
||||
}),
|
||||
settings: JSON.stringify({ webdavPass: encryptSecret('pass'), yuqueToken: 'keep-non-encrypted?' }),
|
||||
_persist: JSON.stringify({ version: 183, rehydrated: true })
|
||||
})
|
||||
|
||||
const stripped = stripPersistedRootStateSecretsString(persisted)
|
||||
const root = JSON.parse(stripped) as Record<string, string>
|
||||
const llm = JSON.parse(root.llm)
|
||||
const settings = JSON.parse(root.settings)
|
||||
|
||||
expect(llm.providers[0].apiKey).toBe('')
|
||||
expect(llm.settings.awsBedrock.secretAccessKey).toBe('')
|
||||
expect(settings.webdavPass).toBe('')
|
||||
expect(settings.yuqueToken).toBe('')
|
||||
})
|
||||
})
|
||||
51
src/renderer/src/utils/__tests__/secureStorage.test.ts
Normal file
51
src/renderer/src/utils/__tests__/secureStorage.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '../secureStorage'
|
||||
|
||||
describe('secureStorage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
const safeStorage = {
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
encryptString: vi.fn((plainText: string) => {
|
||||
if (plainText.startsWith('csenc:')) return plainText
|
||||
return `csenc:${Buffer.from(plainText, 'utf-8').toString('base64')}`
|
||||
}),
|
||||
decryptString: vi.fn((value: string) => {
|
||||
const prefix = 'csenc:'
|
||||
if (!value.startsWith(prefix)) return value
|
||||
try {
|
||||
return Buffer.from(value.slice(prefix.length), 'base64').toString('utf-8')
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
;(window as any).api = { ...(window as any).api, safeStorage }
|
||||
})
|
||||
|
||||
it('migrates legacy plaintext localStorage values to encrypted-at-rest storage', () => {
|
||||
localStorage.setItem('plain', 'value')
|
||||
|
||||
expect(getDecryptedLocalStorageItem('plain')).toBe('value')
|
||||
expect(localStorage.getItem('plain')).toMatch(/^csenc:/)
|
||||
})
|
||||
|
||||
it('stores encrypted values via setEncryptedLocalStorageItem and reads them back', () => {
|
||||
setEncryptedLocalStorageItem('token', 'secret-token')
|
||||
expect(localStorage.getItem('token')).toMatch(/^csenc:/)
|
||||
expect(getDecryptedLocalStorageItem('token')).toBe('secret-token')
|
||||
})
|
||||
|
||||
it('removes encrypted localStorage values that can no longer be decrypted', () => {
|
||||
setEncryptedLocalStorageItem('token', 'secret-token')
|
||||
expect(localStorage.getItem('token')).toMatch(/^csenc:/)
|
||||
|
||||
;(window as any).api.safeStorage.decryptString = vi.fn((value: string) => value)
|
||||
|
||||
expect(getDecryptedLocalStorageItem('token')).toBeNull()
|
||||
expect(localStorage.getItem('token')).toBeNull()
|
||||
})
|
||||
})
|
||||
238
src/renderer/src/utils/securePersist.ts
Normal file
238
src/renderer/src/utils/securePersist.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import { decryptSecretWithIssue, encryptSecret } from './secureStorage'
|
||||
|
||||
export const SECURE_PERSIST_SLICE_KEYS = ['llm', 'settings', 'preprocess', 'websearch', 'nutstore'] as const
|
||||
export type SecurePersistSliceKey = (typeof SECURE_PERSIST_SLICE_KEYS)[number]
|
||||
|
||||
type TransformSecret = (value: string, context?: { id: string; meta?: Record<string, unknown> }) => string
|
||||
|
||||
const transformProvidersApiKeys = (sliceKey: string, state: any, transformSecret: TransformSecret) => {
|
||||
return {
|
||||
...state,
|
||||
providers: Array.isArray(state.providers)
|
||||
? state.providers.map((provider: any, index: number) => {
|
||||
const providerId = typeof provider.id === 'string' ? provider.id : String(index)
|
||||
return {
|
||||
...provider,
|
||||
apiKey:
|
||||
typeof provider.apiKey === 'string'
|
||||
? transformSecret(provider.apiKey, {
|
||||
id: `${sliceKey}.providers.${providerId}.apiKey`,
|
||||
meta:
|
||||
typeof provider.id === 'string'
|
||||
? { scope: sliceKey, providerId: provider.id }
|
||||
: { scope: sliceKey }
|
||||
})
|
||||
: provider.apiKey
|
||||
}
|
||||
})
|
||||
: state.providers
|
||||
}
|
||||
}
|
||||
|
||||
export const transformPersistSliceState = (key: string, state: any, transformSecret: TransformSecret) => {
|
||||
if (!state || typeof state !== 'object') {
|
||||
return state
|
||||
}
|
||||
|
||||
if (key === 'llm') {
|
||||
return {
|
||||
...transformProvidersApiKeys('llm', state, transformSecret),
|
||||
settings: {
|
||||
...state.settings,
|
||||
vertexai: state.settings?.vertexai
|
||||
? {
|
||||
...state.settings.vertexai,
|
||||
serviceAccount: state.settings.vertexai.serviceAccount
|
||||
? {
|
||||
...state.settings.vertexai.serviceAccount,
|
||||
privateKey:
|
||||
typeof state.settings.vertexai.serviceAccount.privateKey === 'string'
|
||||
? transformSecret(state.settings.vertexai.serviceAccount.privateKey, {
|
||||
id: 'llm.settings.vertexai.serviceAccount.privateKey',
|
||||
meta: { scope: 'llm', providerId: 'vertexai' }
|
||||
})
|
||||
: state.settings.vertexai.serviceAccount.privateKey
|
||||
}
|
||||
: state.settings.vertexai.serviceAccount
|
||||
}
|
||||
: state.settings?.vertexai,
|
||||
awsBedrock: state.settings?.awsBedrock
|
||||
? {
|
||||
...state.settings.awsBedrock,
|
||||
accessKeyId:
|
||||
typeof state.settings.awsBedrock.accessKeyId === 'string'
|
||||
? transformSecret(state.settings.awsBedrock.accessKeyId, {
|
||||
id: 'llm.settings.awsBedrock.accessKeyId',
|
||||
meta: { scope: 'llm', providerId: 'awsBedrock' }
|
||||
})
|
||||
: state.settings.awsBedrock.accessKeyId,
|
||||
secretAccessKey:
|
||||
typeof state.settings.awsBedrock.secretAccessKey === 'string'
|
||||
? transformSecret(state.settings.awsBedrock.secretAccessKey, {
|
||||
id: 'llm.settings.awsBedrock.secretAccessKey',
|
||||
meta: { scope: 'llm', providerId: 'awsBedrock' }
|
||||
})
|
||||
: state.settings.awsBedrock.secretAccessKey,
|
||||
apiKey:
|
||||
typeof state.settings.awsBedrock.apiKey === 'string'
|
||||
? transformSecret(state.settings.awsBedrock.apiKey, {
|
||||
id: 'llm.settings.awsBedrock.apiKey',
|
||||
meta: { scope: 'llm', providerId: 'awsBedrock' }
|
||||
})
|
||||
: state.settings.awsBedrock.apiKey
|
||||
}
|
||||
: state.settings?.awsBedrock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'settings') {
|
||||
return {
|
||||
...state,
|
||||
webdavPass:
|
||||
typeof state.webdavPass === 'string'
|
||||
? transformSecret(state.webdavPass, { id: 'settings.webdavPass', meta: { scope: 'settings' } })
|
||||
: state.webdavPass,
|
||||
notionApiKey:
|
||||
typeof state.notionApiKey === 'string'
|
||||
? transformSecret(state.notionApiKey, { id: 'settings.notionApiKey', meta: { scope: 'settings' } })
|
||||
: state.notionApiKey,
|
||||
yuqueToken:
|
||||
typeof state.yuqueToken === 'string'
|
||||
? transformSecret(state.yuqueToken, { id: 'settings.yuqueToken', meta: { scope: 'settings' } })
|
||||
: state.yuqueToken,
|
||||
joplinToken:
|
||||
typeof state.joplinToken === 'string'
|
||||
? transformSecret(state.joplinToken, { id: 'settings.joplinToken', meta: { scope: 'settings' } })
|
||||
: state.joplinToken,
|
||||
siyuanToken:
|
||||
typeof state.siyuanToken === 'string'
|
||||
? transformSecret(state.siyuanToken, { id: 'settings.siyuanToken', meta: { scope: 'settings' } })
|
||||
: state.siyuanToken,
|
||||
s3: state.s3
|
||||
? {
|
||||
...state.s3,
|
||||
accessKeyId:
|
||||
typeof state.s3.accessKeyId === 'string'
|
||||
? transformSecret(state.s3.accessKeyId, { id: 'settings.s3.accessKeyId', meta: { scope: 'settings' } })
|
||||
: state.s3.accessKeyId,
|
||||
secretAccessKey:
|
||||
typeof state.s3.secretAccessKey === 'string'
|
||||
? transformSecret(state.s3.secretAccessKey, {
|
||||
id: 'settings.s3.secretAccessKey',
|
||||
meta: { scope: 'settings' }
|
||||
})
|
||||
: state.s3.secretAccessKey
|
||||
}
|
||||
: state.s3,
|
||||
apiServer: state.apiServer
|
||||
? {
|
||||
...state.apiServer,
|
||||
apiKey:
|
||||
typeof state.apiServer.apiKey === 'string'
|
||||
? transformSecret(state.apiServer.apiKey, {
|
||||
id: 'settings.apiServer.apiKey',
|
||||
meta: { scope: 'settings' }
|
||||
})
|
||||
: state.apiServer.apiKey
|
||||
}
|
||||
: state.apiServer
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'preprocess' || key === 'websearch') {
|
||||
return transformProvidersApiKeys(key, state, transformSecret)
|
||||
}
|
||||
|
||||
if (key === 'nutstore') {
|
||||
return {
|
||||
...state,
|
||||
nutstoreToken:
|
||||
typeof state.nutstoreToken === 'string'
|
||||
? transformSecret(state.nutstoreToken, { id: 'nutstore.nutstoreToken', meta: { scope: 'nutstore' } })
|
||||
: state.nutstoreToken
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
const encryptSecretTransform: TransformSecret = (value) => encryptSecret(value)
|
||||
const decryptSecretTransform: TransformSecret = (value, context) =>
|
||||
decryptSecretWithIssue(value, context?.id, context?.meta)
|
||||
|
||||
export const encryptPersistSliceState = (key: string, state: any) =>
|
||||
transformPersistSliceState(key, state, encryptSecretTransform)
|
||||
export const decryptPersistSliceState = (key: string, state: any) =>
|
||||
transformPersistSliceState(key, state, decryptSecretTransform)
|
||||
export const stripPersistSliceSecrets = (key: string, state: any) => transformPersistSliceState(key, state, () => '')
|
||||
|
||||
export type PersistedRootState = Record<string, unknown>
|
||||
|
||||
export const transformPersistedRootStateString = (persistedValue: string, direction: 'encrypt' | 'decrypt'): string => {
|
||||
const transformSlice = direction === 'encrypt' ? encryptPersistSliceState : decryptPersistSliceState
|
||||
|
||||
try {
|
||||
const root = JSON.parse(persistedValue) as PersistedRootState
|
||||
if (!root || typeof root !== 'object') {
|
||||
return persistedValue
|
||||
}
|
||||
|
||||
let changed = false
|
||||
for (const sliceKey of SECURE_PERSIST_SLICE_KEYS) {
|
||||
const rawSlice = root[sliceKey]
|
||||
if (typeof rawSlice !== 'string') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedSlice = JSON.parse(rawSlice)
|
||||
const transformedSlice = transformSlice(sliceKey, parsedSlice)
|
||||
const nextRawSlice = JSON.stringify(transformedSlice)
|
||||
if (nextRawSlice !== rawSlice) {
|
||||
root[sliceKey] = nextRawSlice
|
||||
changed = true
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed slice payloads and keep original.
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? JSON.stringify(root) : persistedValue
|
||||
} catch {
|
||||
return persistedValue
|
||||
}
|
||||
}
|
||||
|
||||
export const stripPersistedRootStateSecretsString = (persistedValue: string): string => {
|
||||
try {
|
||||
const root = JSON.parse(persistedValue) as PersistedRootState
|
||||
if (!root || typeof root !== 'object') {
|
||||
return persistedValue
|
||||
}
|
||||
|
||||
let changed = false
|
||||
for (const sliceKey of SECURE_PERSIST_SLICE_KEYS) {
|
||||
const rawSlice = root[sliceKey]
|
||||
if (typeof rawSlice !== 'string') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedSlice = JSON.parse(rawSlice)
|
||||
const strippedSlice = stripPersistSliceSecrets(sliceKey, parsedSlice)
|
||||
const nextRawSlice = JSON.stringify(strippedSlice)
|
||||
if (nextRawSlice !== rawSlice) {
|
||||
root[sliceKey] = nextRawSlice
|
||||
changed = true
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed slice payloads and keep original.
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? JSON.stringify(root) : persistedValue
|
||||
} catch {
|
||||
return persistedValue
|
||||
}
|
||||
}
|
||||
106
src/renderer/src/utils/secureStorage.ts
Normal file
106
src/renderer/src/utils/secureStorage.ts
Normal file
@ -0,0 +1,106 @@
|
||||
const ENCRYPTION_PREFIX = 'csenc:'
|
||||
|
||||
export const CREDENTIAL_ISSUE_EVENT_NAME = 'cherrystudio:credential-issue'
|
||||
|
||||
export type CredentialIssueReason = 'decrypt_failed'
|
||||
|
||||
export interface CredentialIssue {
|
||||
id: string
|
||||
reason: CredentialIssueReason
|
||||
timestamp: number
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const credentialIssueQueue: CredentialIssue[] = []
|
||||
const credentialIssueKeys = new Set<string>()
|
||||
|
||||
const publishCredentialIssue = (issue: CredentialIssue): void => {
|
||||
const key = `${issue.reason}:${issue.id}`
|
||||
if (credentialIssueKeys.has(key)) return
|
||||
credentialIssueKeys.add(key)
|
||||
credentialIssueQueue.push(issue)
|
||||
|
||||
try {
|
||||
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
|
||||
window.dispatchEvent(new CustomEvent(CREDENTIAL_ISSUE_EVENT_NAME, { detail: issue }))
|
||||
}
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
|
||||
export const reportCredentialDecryptFailure = (id: string, meta?: Record<string, unknown>): void => {
|
||||
publishCredentialIssue({
|
||||
id,
|
||||
reason: 'decrypt_failed',
|
||||
timestamp: Date.now(),
|
||||
meta
|
||||
})
|
||||
}
|
||||
|
||||
export const consumeCredentialIssues = (): CredentialIssue[] => {
|
||||
const issues = credentialIssueQueue.splice(0, credentialIssueQueue.length)
|
||||
credentialIssueKeys.clear()
|
||||
return issues
|
||||
}
|
||||
|
||||
const isEncryptionAvailable = (): boolean => {
|
||||
try {
|
||||
return Boolean(window.api?.safeStorage?.isEncryptionAvailable?.())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const encryptSecret = (value: string): string => {
|
||||
try {
|
||||
return window.api?.safeStorage?.encryptString?.(value) ?? value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export const decryptSecret = (value: string): string => {
|
||||
try {
|
||||
const decrypted = window.api?.safeStorage?.decryptString?.(value) ?? value
|
||||
if (value.startsWith(ENCRYPTION_PREFIX) && decrypted === value) {
|
||||
return ''
|
||||
}
|
||||
return decrypted
|
||||
} catch {
|
||||
return value.startsWith(ENCRYPTION_PREFIX) ? '' : value
|
||||
}
|
||||
}
|
||||
|
||||
export const decryptSecretWithIssue = (value: string, issueId?: string, meta?: Record<string, unknown>): string => {
|
||||
const decrypted = decryptSecret(value)
|
||||
if (issueId && value.startsWith(ENCRYPTION_PREFIX) && decrypted === '') {
|
||||
reportCredentialDecryptFailure(issueId, meta)
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
|
||||
export const setEncryptedLocalStorageItem = (key: string, value: string): void => {
|
||||
localStorage.setItem(key, encryptSecret(value))
|
||||
}
|
||||
|
||||
export const getDecryptedLocalStorageItem = (key: string): string | null => {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const decrypted = decryptSecretWithIssue(raw, `localStorage.${key}`, { scope: 'localStorage', storageKey: key })
|
||||
|
||||
if (raw.startsWith(ENCRYPTION_PREFIX) && decrypted === '') {
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
|
||||
// Migrate legacy plaintext to encrypted-at-rest storage when available.
|
||||
if (!raw.startsWith(ENCRYPTION_PREFIX) && isEncryptionAvailable()) {
|
||||
setEncryptedLocalStorageItem(key, raw)
|
||||
}
|
||||
|
||||
return decrypted
|
||||
}
|
||||
@ -16,6 +16,12 @@ vi.mock('electron', () => ({
|
||||
switch (key) {
|
||||
case 'userData':
|
||||
return '/mock/userData'
|
||||
case 'documents':
|
||||
return '/mock/documents'
|
||||
case 'downloads':
|
||||
return '/mock/downloads'
|
||||
case 'desktop':
|
||||
return '/mock/desktop'
|
||||
case 'temp':
|
||||
return '/mock/temp'
|
||||
case 'logs':
|
||||
@ -26,6 +32,20 @@ vi.mock('electron', () => ({
|
||||
}),
|
||||
getVersion: vi.fn(() => '1.0.0')
|
||||
},
|
||||
net: {
|
||||
fetch: vi.fn()
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
encryptString: vi.fn((plainText: string) => Buffer.from(`encrypted:${plainText}`, 'utf-8')),
|
||||
decryptString: vi.fn((encrypted: Buffer) => {
|
||||
const value = encrypted.toString('utf-8')
|
||||
if (!value.startsWith('encrypted:')) {
|
||||
throw new Error('Unable to decrypt')
|
||||
}
|
||||
return value.slice('encrypted:'.length)
|
||||
})
|
||||
},
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
on: vi.fn(),
|
||||
@ -98,13 +118,18 @@ vi.mock('winston-daily-rotate-file', () => {
|
||||
})
|
||||
|
||||
// Mock Node.js modules
|
||||
vi.mock('node:os', () => ({
|
||||
platform: vi.fn(() => 'darwin'),
|
||||
arch: vi.fn(() => 'x64'),
|
||||
version: vi.fn(() => '20.0.0'),
|
||||
cpus: vi.fn(() => [{ model: 'Mock CPU' }]),
|
||||
totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB
|
||||
}))
|
||||
vi.mock('node:os', () => {
|
||||
const api = {
|
||||
platform: vi.fn(() => 'darwin'),
|
||||
arch: vi.fn(() => 'x64'),
|
||||
version: vi.fn(() => '20.0.0'),
|
||||
homedir: vi.fn(() => '/mock/home'),
|
||||
cpus: vi.fn(() => [{ model: 'Mock CPU' }]),
|
||||
totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB
|
||||
}
|
||||
|
||||
return { ...api, default: api }
|
||||
})
|
||||
|
||||
vi.mock('node:path', async () => {
|
||||
const actual = await vi.importActual('node:path')
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { styleSheetSerializer } from 'jest-styled-components/serializer'
|
||||
import { expect, vi } from 'vitest'
|
||||
|
||||
expect.addSnapshotSerializer(styleSheetSerializer)
|
||||
|
||||
// Node.js >= 25 removed `buffer.SlowBuffer`, but some transitive deps still assume it exists.
|
||||
const require = createRequire(import.meta.url)
|
||||
const bufferModule = require('buffer')
|
||||
if (!bufferModule.SlowBuffer) {
|
||||
bufferModule.SlowBuffer = bufferModule.Buffer
|
||||
}
|
||||
|
||||
// Mock LoggerService globally for renderer tests
|
||||
vi.mock('@logger', async () => {
|
||||
const { MockRendererLoggerService, mockRendererLoggerService } = await import('./__mocks__/RendererLoggerService')
|
||||
@ -48,3 +56,32 @@ vi.stubGlobal('api', {
|
||||
writeWithId: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
// Node.js >= 25 exposes a non-standard `localStorage`/`sessionStorage` global by default.
|
||||
// In jsdom tests we want the standard Web Storage API from the jsdom window.
|
||||
const createStorageMock = () => {
|
||||
const data = new Map<string, string>()
|
||||
return {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
data.set(key, String(value))
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
data.delete(key)
|
||||
},
|
||||
clear: () => {
|
||||
data.clear()
|
||||
},
|
||||
key: (index: number) => Array.from(data.keys())[index] ?? null,
|
||||
get length() {
|
||||
return data.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const localStorageImpl = typeof window?.localStorage?.getItem === 'function' ? window.localStorage : createStorageMock()
|
||||
vi.stubGlobal('localStorage', localStorageImpl)
|
||||
|
||||
const sessionStorageImpl =
|
||||
typeof window?.sessionStorage?.getItem === 'function' ? window.sessionStorage : createStorageMock()
|
||||
vi.stubGlobal('sessionStorage', sessionStorageImpl)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user