chore: sync current workspace changes

This commit is contained in:
William Wang 2025-12-16 07:14:04 +08:00
parent 71df9d61fd
commit dc8a7331d9
14 changed files with 517 additions and 46 deletions

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

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

View File

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

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

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

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

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

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

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