mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 03:31:24 +08:00
chore: sync current workspace changes
This commit is contained in:
parent
71df9d61fd
commit
dc8a7331d9
@ -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
|
||||
|
||||
@ -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,39 @@ 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
|
||||
}
|
||||
|
||||
const encrypted = safeStorage.encryptString(JSON.stringify(creds))
|
||||
await promises.writeFile(CREDS_PATH, encrypted)
|
||||
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 +187,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)
|
||||
|
||||
@ -85,7 +85,6 @@ export class WindowService {
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
webviewTag: true,
|
||||
allowRunningInsecureContent: true,
|
||||
zoomFactor: configManager.getZoomFactor(),
|
||||
backgroundThrottling: false
|
||||
}
|
||||
@ -301,26 +300,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) {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -29,10 +30,32 @@ export class JsonFileStorage implements IOAuthStorage {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(this.filePath, 'utf-8')
|
||||
const parsed = JSON.parse(data)
|
||||
const raw = await fs.readFile(this.filePath)
|
||||
|
||||
let storageJson: string | undefined
|
||||
let usedEncryptedPayload = false
|
||||
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
storageJson = safeStorage.decryptString(raw)
|
||||
usedEncryptedPayload = true
|
||||
} catch {
|
||||
// Fall back to legacy plain JSON (pre-encryption), and migrate on success.
|
||||
}
|
||||
}
|
||||
|
||||
if (storageJson === undefined) {
|
||||
storageJson = raw.toString('utf-8')
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(storageJson)
|
||||
const validated = OAuthStorageSchema.parse(parsed)
|
||||
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 +79,15 @@ 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()) {
|
||||
await fs.writeFile(tempPath, safeStorage.encryptString(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
|
||||
|
||||
@ -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 {
|
||||
@ -394,6 +394,38 @@ const api = {
|
||||
shell: {
|
||||
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||
encryptString: (plainText: string) => {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return plainText
|
||||
}
|
||||
if (plainText.startsWith('csenc:')) {
|
||||
return plainText
|
||||
}
|
||||
try {
|
||||
const encrypted = safeStorage.encryptString(plainText)
|
||||
return `csenc:${encrypted.toString('base64')}`
|
||||
} catch {
|
||||
return plainText
|
||||
}
|
||||
},
|
||||
decryptString: (value: string) => {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return value
|
||||
}
|
||||
const prefix = 'csenc:'
|
||||
if (!value.startsWith(prefix)) {
|
||||
return value
|
||||
}
|
||||
try {
|
||||
const payload = value.slice(prefix.length)
|
||||
return safeStorage.decryptString(Buffer.from(payload, 'base64'))
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
},
|
||||
copilot: {
|
||||
getAuthMessage: (headers?: Record<string, string>) =>
|
||||
ipcRenderer.invoke(IpcChannel.Copilot_GetAuthMessage, headers),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('302ai')
|
||||
@ -10,11 +11,11 @@ const TOKEN_STORAGE_KEY = 'ai302_token'
|
||||
export const AI302_HOST = 'https://api.302.ai/mcp'
|
||||
|
||||
export const saveAI302Token = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getAI302Token = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearAI302Token = (): void => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('BailianSyncUtils')
|
||||
@ -11,12 +12,11 @@ const TOKEN_STORAGE_KEY = 'bailian_token'
|
||||
|
||||
// Token 工具函数
|
||||
export const saveBailianToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getBailianToken = (): string | null => {
|
||||
const token = localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return token
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearBailianToken = (): void => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('TokenLanYunSyncUtils')
|
||||
@ -11,11 +12,11 @@ export const LANYUN_MCP_HOST = TOKENLANYUN_HOST + '/mcp/manager/selectListByApiK
|
||||
export const LANYUN_KEY_HOST = TOKENLANYUN_HOST + '/#/manage/apiKey'
|
||||
|
||||
export const saveTokenLanYunToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getTokenLanYunToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearTokenLanYunToken = (): void => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('MCPRouterSyncUtils')
|
||||
@ -10,11 +11,11 @@ const TOKEN_STORAGE_KEY = 'mcprouter_token'
|
||||
export const MCPROUTER_HOST = 'https://mcprouter.co'
|
||||
|
||||
export const saveMCPRouterToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getMCPRouterToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearMCPRouterToken = (): void => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { getMcpServerType, type MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('ModelScopeSyncUtils')
|
||||
@ -10,11 +11,11 @@ const TOKEN_STORAGE_KEY = 'modelscope_token'
|
||||
export const MODELSCOPE_HOST = 'https://www.modelscope.cn'
|
||||
|
||||
export const saveModelScopeToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getModelScopeToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearModelScopeToken = (): void => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { MCPServer } from '@renderer/types'
|
||||
import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '@renderer/utils/secureStorage'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('TokenFluxSyncUtils')
|
||||
@ -10,11 +11,11 @@ const TOKEN_STORAGE_KEY = 'tokenflux_token'
|
||||
export const TOKENFLUX_HOST = 'https://tokenflux.ai'
|
||||
|
||||
export const saveTokenFluxToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
setEncryptedLocalStorageItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
|
||||
export const getTokenFluxToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
return getDecryptedLocalStorageItem(TOKEN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export const clearTokenFluxToken = (): void => {
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
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 { decryptSecret, encryptSecret } from '../utils/secureStorage'
|
||||
import assistants from './assistants'
|
||||
import backup from './backup'
|
||||
import codeTools from './codeTools'
|
||||
@ -35,6 +46,282 @@ import websearch from './websearch'
|
||||
|
||||
const logger = loggerService.withContext('Store')
|
||||
|
||||
const securePersistTransform = createTransform(
|
||||
(inboundState: any, key) => {
|
||||
if (!inboundState || typeof inboundState !== 'object') {
|
||||
return inboundState
|
||||
}
|
||||
|
||||
if (key === 'llm') {
|
||||
return {
|
||||
...inboundState,
|
||||
providers: Array.isArray(inboundState.providers)
|
||||
? inboundState.providers.map((provider: any) => ({
|
||||
...provider,
|
||||
apiKey: typeof provider.apiKey === 'string' ? encryptSecret(provider.apiKey) : provider.apiKey
|
||||
}))
|
||||
: inboundState.providers,
|
||||
settings: {
|
||||
...inboundState.settings,
|
||||
vertexai: inboundState.settings?.vertexai
|
||||
? {
|
||||
...inboundState.settings.vertexai,
|
||||
serviceAccount: inboundState.settings.vertexai.serviceAccount
|
||||
? {
|
||||
...inboundState.settings.vertexai.serviceAccount,
|
||||
privateKey:
|
||||
typeof inboundState.settings.vertexai.serviceAccount.privateKey === 'string'
|
||||
? encryptSecret(inboundState.settings.vertexai.serviceAccount.privateKey)
|
||||
: inboundState.settings.vertexai.serviceAccount.privateKey
|
||||
}
|
||||
: inboundState.settings.vertexai.serviceAccount
|
||||
}
|
||||
: inboundState.settings?.vertexai,
|
||||
awsBedrock: inboundState.settings?.awsBedrock
|
||||
? {
|
||||
...inboundState.settings.awsBedrock,
|
||||
accessKeyId:
|
||||
typeof inboundState.settings.awsBedrock.accessKeyId === 'string'
|
||||
? encryptSecret(inboundState.settings.awsBedrock.accessKeyId)
|
||||
: inboundState.settings.awsBedrock.accessKeyId,
|
||||
secretAccessKey:
|
||||
typeof inboundState.settings.awsBedrock.secretAccessKey === 'string'
|
||||
? encryptSecret(inboundState.settings.awsBedrock.secretAccessKey)
|
||||
: inboundState.settings.awsBedrock.secretAccessKey,
|
||||
apiKey:
|
||||
typeof inboundState.settings.awsBedrock.apiKey === 'string'
|
||||
? encryptSecret(inboundState.settings.awsBedrock.apiKey)
|
||||
: inboundState.settings.awsBedrock.apiKey
|
||||
}
|
||||
: inboundState.settings?.awsBedrock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'settings') {
|
||||
return {
|
||||
...inboundState,
|
||||
webdavPass:
|
||||
typeof inboundState.webdavPass === 'string'
|
||||
? encryptSecret(inboundState.webdavPass)
|
||||
: inboundState.webdavPass,
|
||||
notionApiKey:
|
||||
typeof inboundState.notionApiKey === 'string'
|
||||
? encryptSecret(inboundState.notionApiKey)
|
||||
: inboundState.notionApiKey,
|
||||
yuqueToken:
|
||||
typeof inboundState.yuqueToken === 'string'
|
||||
? encryptSecret(inboundState.yuqueToken)
|
||||
: inboundState.yuqueToken,
|
||||
joplinToken:
|
||||
typeof inboundState.joplinToken === 'string'
|
||||
? encryptSecret(inboundState.joplinToken)
|
||||
: inboundState.joplinToken,
|
||||
siyuanToken:
|
||||
typeof inboundState.siyuanToken === 'string'
|
||||
? encryptSecret(inboundState.siyuanToken)
|
||||
: inboundState.siyuanToken,
|
||||
s3: inboundState.s3
|
||||
? {
|
||||
...inboundState.s3,
|
||||
accessKeyId:
|
||||
typeof inboundState.s3.accessKeyId === 'string'
|
||||
? encryptSecret(inboundState.s3.accessKeyId)
|
||||
: inboundState.s3.accessKeyId,
|
||||
secretAccessKey:
|
||||
typeof inboundState.s3.secretAccessKey === 'string'
|
||||
? encryptSecret(inboundState.s3.secretAccessKey)
|
||||
: inboundState.s3.secretAccessKey
|
||||
}
|
||||
: inboundState.s3,
|
||||
apiServer: inboundState.apiServer
|
||||
? {
|
||||
...inboundState.apiServer,
|
||||
apiKey:
|
||||
typeof inboundState.apiServer.apiKey === 'string'
|
||||
? encryptSecret(inboundState.apiServer.apiKey)
|
||||
: inboundState.apiServer.apiKey
|
||||
}
|
||||
: inboundState.apiServer
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'preprocess') {
|
||||
return {
|
||||
...inboundState,
|
||||
providers: Array.isArray(inboundState.providers)
|
||||
? inboundState.providers.map((provider: any) => ({
|
||||
...provider,
|
||||
apiKey: typeof provider.apiKey === 'string' ? encryptSecret(provider.apiKey) : provider.apiKey
|
||||
}))
|
||||
: inboundState.providers
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'websearch') {
|
||||
return {
|
||||
...inboundState,
|
||||
providers: Array.isArray(inboundState.providers)
|
||||
? inboundState.providers.map((provider: any) => ({
|
||||
...provider,
|
||||
apiKey: typeof provider.apiKey === 'string' ? encryptSecret(provider.apiKey) : provider.apiKey
|
||||
}))
|
||||
: inboundState.providers
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'nutstore') {
|
||||
return {
|
||||
...inboundState,
|
||||
nutstoreToken:
|
||||
typeof inboundState.nutstoreToken === 'string'
|
||||
? encryptSecret(inboundState.nutstoreToken)
|
||||
: inboundState.nutstoreToken
|
||||
}
|
||||
}
|
||||
|
||||
return inboundState
|
||||
},
|
||||
(outboundState: any, key) => {
|
||||
if (!outboundState || typeof outboundState !== 'object') {
|
||||
return outboundState
|
||||
}
|
||||
|
||||
if (key === 'llm') {
|
||||
return {
|
||||
...outboundState,
|
||||
providers: Array.isArray(outboundState.providers)
|
||||
? outboundState.providers.map((provider: any) => ({
|
||||
...provider,
|
||||
apiKey: typeof provider.apiKey === 'string' ? decryptSecret(provider.apiKey) : provider.apiKey
|
||||
}))
|
||||
: outboundState.providers,
|
||||
settings: {
|
||||
...outboundState.settings,
|
||||
vertexai: outboundState.settings?.vertexai
|
||||
? {
|
||||
...outboundState.settings.vertexai,
|
||||
serviceAccount: outboundState.settings.vertexai.serviceAccount
|
||||
? {
|
||||
...outboundState.settings.vertexai.serviceAccount,
|
||||
privateKey:
|
||||
typeof outboundState.settings.vertexai.serviceAccount.privateKey === 'string'
|
||||
? decryptSecret(outboundState.settings.vertexai.serviceAccount.privateKey)
|
||||
: outboundState.settings.vertexai.serviceAccount.privateKey
|
||||
}
|
||||
: outboundState.settings.vertexai.serviceAccount
|
||||
}
|
||||
: outboundState.settings?.vertexai,
|
||||
awsBedrock: outboundState.settings?.awsBedrock
|
||||
? {
|
||||
...outboundState.settings.awsBedrock,
|
||||
accessKeyId:
|
||||
typeof outboundState.settings.awsBedrock.accessKeyId === 'string'
|
||||
? decryptSecret(outboundState.settings.awsBedrock.accessKeyId)
|
||||
: outboundState.settings.awsBedrock.accessKeyId,
|
||||
secretAccessKey:
|
||||
typeof outboundState.settings.awsBedrock.secretAccessKey === 'string'
|
||||
? decryptSecret(outboundState.settings.awsBedrock.secretAccessKey)
|
||||
: outboundState.settings.awsBedrock.secretAccessKey,
|
||||
apiKey:
|
||||
typeof outboundState.settings.awsBedrock.apiKey === 'string'
|
||||
? decryptSecret(outboundState.settings.awsBedrock.apiKey)
|
||||
: outboundState.settings.awsBedrock.apiKey
|
||||
}
|
||||
: outboundState.settings?.awsBedrock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'settings') {
|
||||
return {
|
||||
...outboundState,
|
||||
webdavPass:
|
||||
typeof outboundState.webdavPass === 'string'
|
||||
? decryptSecret(outboundState.webdavPass)
|
||||
: outboundState.webdavPass,
|
||||
notionApiKey:
|
||||
typeof outboundState.notionApiKey === 'string'
|
||||
? decryptSecret(outboundState.notionApiKey)
|
||||
: outboundState.notionApiKey,
|
||||
yuqueToken:
|
||||
typeof outboundState.yuqueToken === 'string'
|
||||
? decryptSecret(outboundState.yuqueToken)
|
||||
: outboundState.yuqueToken,
|
||||
joplinToken:
|
||||
typeof outboundState.joplinToken === 'string'
|
||||
? decryptSecret(outboundState.joplinToken)
|
||||
: outboundState.joplinToken,
|
||||
siyuanToken:
|
||||
typeof outboundState.siyuanToken === 'string'
|
||||
? decryptSecret(outboundState.siyuanToken)
|
||||
: outboundState.siyuanToken,
|
||||
s3: outboundState.s3
|
||||
? {
|
||||
...outboundState.s3,
|
||||
accessKeyId:
|
||||
typeof outboundState.s3.accessKeyId === 'string'
|
||||
? decryptSecret(outboundState.s3.accessKeyId)
|
||||
: outboundState.s3.accessKeyId,
|
||||
secretAccessKey:
|
||||
typeof outboundState.s3.secretAccessKey === 'string'
|
||||
? decryptSecret(outboundState.s3.secretAccessKey)
|
||||
: outboundState.s3.secretAccessKey
|
||||
}
|
||||
: outboundState.s3,
|
||||
apiServer: outboundState.apiServer
|
||||
? {
|
||||
...outboundState.apiServer,
|
||||
apiKey:
|
||||
typeof outboundState.apiServer.apiKey === 'string'
|
||||
? decryptSecret(outboundState.apiServer.apiKey)
|
||||
: outboundState.apiServer.apiKey
|
||||
}
|
||||
: outboundState.apiServer
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'preprocess') {
|
||||
return {
|
||||
...outboundState,
|
||||
providers: Array.isArray(outboundState.providers)
|
||||
? outboundState.providers.map((provider: any) => ({
|
||||
...provider,
|
||||
apiKey: typeof provider.apiKey === 'string' ? decryptSecret(provider.apiKey) : provider.apiKey
|
||||
}))
|
||||
: outboundState.providers
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'websearch') {
|
||||
return {
|
||||
...outboundState,
|
||||
providers: Array.isArray(outboundState.providers)
|
||||
? outboundState.providers.map((provider: any) => ({
|
||||
...provider,
|
||||
apiKey: typeof provider.apiKey === 'string' ? decryptSecret(provider.apiKey) : provider.apiKey
|
||||
}))
|
||||
: outboundState.providers
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'nutstore') {
|
||||
return {
|
||||
...outboundState,
|
||||
nutstoreToken:
|
||||
typeof outboundState.nutstoreToken === 'string'
|
||||
? decryptSecret(outboundState.nutstoreToken)
|
||||
: outboundState.nutstoreToken
|
||||
}
|
||||
}
|
||||
|
||||
return outboundState
|
||||
},
|
||||
{
|
||||
whitelist: ['llm', 'settings', 'preprocess', 'websearch', 'nutstore']
|
||||
}
|
||||
)
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
assistants,
|
||||
backup,
|
||||
@ -69,9 +356,10 @@ const persistedReducer = persistReducer(
|
||||
storage,
|
||||
version: 183,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
|
||||
transforms: [securePersistTransform],
|
||||
migrate
|
||||
},
|
||||
rootReducer
|
||||
rootReducer as any
|
||||
)
|
||||
|
||||
/**
|
||||
@ -120,6 +408,16 @@ 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)
|
||||
}
|
||||
})
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
||||
|
||||
54
src/renderer/src/utils/secureStorage.ts
Normal file
54
src/renderer/src/utils/secureStorage.ts
Normal file
@ -0,0 +1,54 @@
|
||||
const ENCRYPTION_PREFIX = 'csenc:'
|
||||
|
||||
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 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 = decryptSecret(raw)
|
||||
|
||||
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
|
||||
}
|
||||
@ -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,34 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis.localStorage?.getItem !== 'function') {
|
||||
const storage = typeof window?.localStorage?.getItem === 'function' ? window.localStorage : createStorageMock()
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
}
|
||||
if (typeof globalThis.sessionStorage?.getItem !== 'function') {
|
||||
const storage = typeof window?.sessionStorage?.getItem === 'function' ? window.sessionStorage : createStorageMock()
|
||||
vi.stubGlobal('sessionStorage', storage)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user