This commit is contained in:
GeekMr 2025-12-18 13:41:46 +08:00 committed by GitHub
commit 33ef25bb77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1968 additions and 131 deletions

12
.github/workflows/main.yml vendored Normal file
View 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
View File

@ -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

View File

@ -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

View File

@ -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(),

View File

@ -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)

View File

@ -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
}
}

View File

@ -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',

View File

@ -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
}
})

View File

@ -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
}
})

View 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'))
})
})

View 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()
})
})

View File

@ -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

View 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)
}
})
})

View 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)
)
}

View File

@ -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),

View File

@ -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)" />

View File

@ -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 = () => {

View File

@ -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',

View File

@ -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",

View File

@ -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": "操作",

View File

@ -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": "操作",

View 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

View File

@ -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 => {

View File

@ -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 => {

View File

@ -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 => {

View File

@ -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 => {

View File

@ -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 => {

View File

@ -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 => {

View File

@ -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;
`

View File

@ -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']) {

View File

@ -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>()

View File

@ -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,

View File

@ -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

View 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('')
})
})

View 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()
})
})

View 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
}
}

View 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
}

View File

@ -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')

View File

@ -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)