cherry-studio/src/main/ipc.ts
George·Dong 3f5901766d
feat: Add S3 Backup (#6802)
* chore: import opendal

* feat: 添加S3备份支持及相关设置界面

- 在IpcChannel中新增S3备份相关IPC事件,支持备份、恢复、
  列表、删除文件及连接检测
- 在ipc主进程注册对应的S3备份处理函数,集成backupManager
- 新增S3设置页面,支持配置Endpoint、Region、Bucket、AccessKey等
  参数,并提供同步和备份策略的UI控制
- 删除未使用的RemoteStorage.ts,简化代码库

提升备份功能的灵活性,支持S3作为远程存储目标

* feat(S3 Backup): 完善S3备份功能

- 支持自动备份
- 优化设置前端
- 优化备份恢复代码

* feat(i18n): add S3 storage translations

* feat(settings): 优化数据设置页面和S3设置页面UI

* feat(settings): optimize S3 settings state structure and update usage

* refactor: simplify S3 backup and restore modal logic

* feat(s3 backup): improve S3 settings defaults and modal props

* fix(i18n): optimize S3 access key translations

* feat(backup): optimize logging and progress reporting

* fix(settings): set S3 maxBackups as unlimited by default

* chore(package): restore opendal dependency in package.json

---------

Co-authored-by: suyao <sy20010504@gmail.com>
2025-06-28 22:19:37 +08:00

523 lines
20 KiB
TypeScript

import fs from 'node:fs'
import { arch } from 'node:os'
import path from 'node:path'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom'
import { UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, dialog, ipcMain, session, shell, webContents } from 'electron'
import log from 'electron-log'
import { Notification } from 'src/renderer/src/types/notification'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import { configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService'
import { ExportService } from './services/ExportService'
import FileService from './services/FileService'
import FileStorage from './services/FileStorage'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import storeSyncService from './services/StoreSyncService'
import { themeService } from './services/ThemeService'
import VertexAIService from './services/VertexAIService'
import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateAppDataConfig } from './utils/file'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
const backupManager = new BackupManager()
const exportService = new ExportService(fileManager)
const obsidianVaultService = new ObsidianVaultService()
const vertexAIService = VertexAIService.getInstance()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
const notificationService = new NotificationService(mainWindow)
// Initialize Python service with main window
pythonService.setMainWindow(mainWindow)
ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
appPath: app.getAppPath(),
filesPath: getFilesDir(),
configPath: getConfigDir(),
appDataPath: app.getPath('userData'),
resourcesPath: getResourcePath(),
logsPath: log.transports.file.getFile().path,
arch: arch(),
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env,
installPath: path.dirname(app.getPath('exe'))
}))
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
let proxyConfig: ProxyConfig
if (proxy === 'system') {
proxyConfig = { mode: 'system' }
} else if (proxy) {
proxyConfig = { mode: 'custom', url: proxy }
} else {
proxyConfig = { mode: 'none' }
}
await proxyManager.configureProxy(proxyConfig)
})
ipcMain.handle(IpcChannel.App_Reload, () => mainWindow.reload())
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
configManager.setLanguage(language)
})
// spell check
ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => {
// disable spell check for all webviews
const webviews = webContents.getAllWebContents()
webviews.forEach((webview) => {
webview.session.setSpellCheckerEnabled(isEnable)
})
})
// spell check languages
ipcMain.handle(IpcChannel.App_SetSpellCheckLanguages, (_, languages: string[]) => {
if (languages.length === 0) {
return
}
const windows = BrowserWindow.getAllWindows()
windows.forEach((window) => {
window.webContents.session.setSpellCheckerLanguages(languages)
})
configManager.set('spellCheckLanguages', languages)
})
// launch on boot
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac
// linux is not supported because it requires more file operations
if (isWin || isMac) {
app.setLoginItemSettings({ openAtLogin })
}
})
// launch to tray
ipcMain.handle(IpcChannel.App_SetLaunchToTray, (_, isActive: boolean) => {
configManager.setLaunchToTray(isActive)
})
// tray
ipcMain.handle(IpcChannel.App_SetTray, (_, isActive: boolean) => {
configManager.setTray(isActive)
})
// to tray on close
ipcMain.handle(IpcChannel.App_SetTrayOnClose, (_, isActive: boolean) => {
configManager.setTrayOnClose(isActive)
})
// auto update
ipcMain.handle(IpcChannel.App_SetAutoUpdate, (_, isActive: boolean) => {
appUpdater.setAutoUpdate(isActive)
configManager.setAutoUpdate(isActive)
})
ipcMain.handle(IpcChannel.App_SetTestPlan, async (_, isActive: boolean) => {
log.info('set test plan', isActive)
if (isActive !== configManager.getTestPlan()) {
appUpdater.cancelDownload()
configManager.setTestPlan(isActive)
}
})
ipcMain.handle(IpcChannel.App_SetTestChannel, async (_, channel: UpgradeChannel) => {
log.info('set test channel', channel)
if (channel !== configManager.getTestChannel()) {
appUpdater.cancelDownload()
configManager.setTestChannel(channel)
}
})
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
configManager.set(key, value, isNotify)
})
ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => {
return configManager.get(key)
})
// theme
ipcMain.handle(IpcChannel.App_SetTheme, (_, theme: ThemeMode) => {
themeService.setTheme(theme)
})
ipcMain.handle(IpcChannel.App_HandleZoomFactor, (_, delta: number, reset: boolean = false) => {
const windows = BrowserWindow.getAllWindows()
handleZoomFactor(windows, delta, reset)
return configManager.getZoomFactor()
})
// clear cache
ipcMain.handle(IpcChannel.App_ClearCache, async () => {
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
try {
await Promise.all(
sessions.map(async (session) => {
await session.clearCache()
await session.clearStorageData({
storages: ['cookies', 'filesystem', 'shadercache', 'websql', 'serviceworkers', 'cachestorage']
})
})
)
await fileManager.clearTemp()
await fs.writeFileSync(log.transports.file.getFile().path, '')
return { success: true }
} catch (error: any) {
log.error('Failed to clear cache:', error)
return { success: false, error: error.message }
}
})
// get cache size
ipcMain.handle(IpcChannel.App_GetCacheSize, async () => {
const cachePath = getCacheDir()
log.info(`Calculating cache size for path: ${cachePath}`)
try {
const sizeInBytes = await calculateDirectorySize(cachePath)
const sizeInMB = (sizeInBytes / (1024 * 1024)).toFixed(2)
return `${sizeInMB}`
} catch (error: any) {
log.error(`Failed to calculate cache size for ${cachePath}: ${error.message}`)
return '0'
}
})
let preventQuitListener: ((event: Electron.Event) => void) | null = null
ipcMain.handle(IpcChannel.App_SetStopQuitApp, (_, stop: boolean = false, reason: string = '') => {
if (stop) {
// Only add listener if not already added
if (!preventQuitListener) {
preventQuitListener = (event: Electron.Event) => {
event.preventDefault()
notificationService.sendNotification({
title: reason,
message: reason
} as Notification)
}
app.on('before-quit', preventQuitListener)
}
} else {
// Remove listener if it exists
if (preventQuitListener) {
app.removeListener('before-quit', preventQuitListener)
preventQuitListener = null
}
}
})
// Select app data path
ipcMain.handle(IpcChannel.App_Select, async (_, options: Electron.OpenDialogOptions) => {
try {
const { canceled, filePaths } = await dialog.showOpenDialog(options)
if (canceled || filePaths.length === 0) {
return null
}
return filePaths[0]
} catch (error: any) {
log.error('Failed to select app data path:', error)
return null
}
})
ipcMain.handle(IpcChannel.App_HasWritePermission, async (_, filePath: string) => {
return hasWritePermission(filePath)
})
// Set app data path
ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => {
updateAppDataConfig(filePath)
app.setPath('userData', filePath)
})
ipcMain.handle(IpcChannel.App_GetDataPathFromArgs, () => {
return process.argv
.slice(1)
.find((arg) => arg.startsWith('--new-data-path='))
?.split('--new-data-path=')[1]
})
ipcMain.handle(IpcChannel.App_FlushAppData, () => {
BrowserWindow.getAllWindows().forEach((w) => {
w.webContents.session.flushStorageData()
w.webContents.session.cookies.flushStore()
w.webContents.session.closeAllConnections()
})
session.defaultSession.flushStorageData()
session.defaultSession.cookies.flushStore()
session.defaultSession.closeAllConnections()
})
ipcMain.handle(IpcChannel.App_IsNotEmptyDir, async (_, path: string) => {
return fs.readdirSync(path).length > 0
})
// Copy user data to new location
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string, occupiedDirs: string[] = []) => {
try {
await fs.promises.cp(oldPath, newPath, {
recursive: true,
filter: (src) => {
if (occupiedDirs.some((dir) => src.startsWith(path.resolve(dir)))) {
return false
}
return true
}
})
return { success: true }
} catch (error: any) {
log.error('Failed to copy user data:', error)
return { success: false, error: error.message }
}
})
// Relaunch app
ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => {
app.relaunch(options)
app.exit(0)
})
// check for update
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
return await appUpdater.checkForUpdates()
})
// notification
ipcMain.handle(IpcChannel.Notification_Send, async (_, notification: Notification) => {
await notificationService.sendNotification(notification)
})
ipcMain.handle(IpcChannel.Notification_OnClick, (_, notification: Notification) => {
mainWindow.webContents.send('notification-click', notification)
})
// zip
ipcMain.handle(IpcChannel.Zip_Compress, (_, text: string) => compress(text))
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))
// system
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
win && win.webContents.toggleDevTools()
})
// backup
ipcMain.handle(IpcChannel.Backup_Backup, backupManager.backup)
ipcMain.handle(IpcChannel.Backup_Restore, backupManager.restore)
ipcMain.handle(IpcChannel.Backup_BackupToWebdav, backupManager.backupToWebdav)
ipcMain.handle(IpcChannel.Backup_RestoreFromWebdav, backupManager.restoreFromWebdav)
ipcMain.handle(IpcChannel.Backup_ListWebdavFiles, backupManager.listWebdavFiles)
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile)
ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3)
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3)
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files)
ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File)
ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection)
// file
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
ipcMain.handle(IpcChannel.File_OpenPath, fileManager.openPath)
ipcMain.handle(IpcChannel.File_Save, fileManager.save)
ipcMain.handle(IpcChannel.File_Select, fileManager.selectFile)
ipcMain.handle(IpcChannel.File_Upload, fileManager.uploadFile)
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear)
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile)
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile)
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile)
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder)
ipcMain.handle(IpcChannel.File_Create, fileManager.createTempFile)
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile)
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId)
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image)
ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image)
ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File)
ipcMain.handle(IpcChannel.File_GetPdfInfo, fileManager.pdfPageCount)
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
// fs
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
// export
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord)
// open path
ipcMain.handle(IpcChannel.Open_Path, async (_, path: string) => {
await shell.openPath(path)
})
// shortcuts
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
configManager.setShortcuts(shortcuts)
// Refresh shortcuts registration
if (mainWindow) {
unregisterAllShortcuts()
registerShortcuts(mainWindow)
}
})
// knowledge base
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create)
ipcMain.handle(IpcChannel.KnowledgeBase_Reset, KnowledgeService.reset)
ipcMain.handle(IpcChannel.KnowledgeBase_Delete, KnowledgeService.delete)
ipcMain.handle(IpcChannel.KnowledgeBase_Add, KnowledgeService.add)
ipcMain.handle(IpcChannel.KnowledgeBase_Remove, KnowledgeService.remove)
ipcMain.handle(IpcChannel.KnowledgeBase_Search, KnowledgeService.search)
ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank)
// window
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
mainWindow?.setMinimumSize(width, height)
})
ipcMain.handle(IpcChannel.Windows_ResetMinimumSize, () => {
mainWindow?.setMinimumSize(1080, 600)
const [width, height] = mainWindow?.getSize() ?? [1080, 600]
if (width < 1080) {
mainWindow?.setSize(1080, height)
}
})
// VertexAI
ipcMain.handle(IpcChannel.VertexAI_GetAuthHeaders, async (_, params) => {
return vertexAIService.getAuthHeaders(params)
})
ipcMain.handle(IpcChannel.VertexAI_ClearAuthCache, async (_, projectId: string, clientEmail?: string) => {
vertexAIService.clearAuthCache(projectId, clientEmail)
})
// mini window
ipcMain.handle(IpcChannel.MiniWindow_Show, () => windowService.showMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Hide, () => windowService.hideMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Close, () => windowService.closeMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Toggle, () => windowService.toggleMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_SetPin, (_, isPinned) => windowService.setPinMiniWindow(isPinned))
// aes
ipcMain.handle(IpcChannel.Aes_Encrypt, (_, text: string, secretKey: string, iv: string) =>
encrypt(text, secretKey, iv)
)
ipcMain.handle(IpcChannel.Aes_Decrypt, (_, encryptedData: string, iv: string, secretKey: string) =>
decrypt(encryptedData, iv, secretKey)
)
// Register MCP handlers
ipcMain.handle(IpcChannel.Mcp_RemoveServer, mcpService.removeServer)
ipcMain.handle(IpcChannel.Mcp_RestartServer, mcpService.restartServer)
ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer)
ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools)
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts)
ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt)
ipcMain.handle(IpcChannel.Mcp_ListResources, mcpService.listResources)
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
// Register Python execution handler
ipcMain.handle(
IpcChannel.Python_Execute,
async (_, script: string, context?: Record<string, any>, timeout?: number) => {
return await pythonService.executeScript(script, context, timeout)
}
)
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
//copilot
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage)
ipcMain.handle(IpcChannel.Copilot_GetCopilotToken, CopilotService.getCopilotToken)
ipcMain.handle(IpcChannel.Copilot_SaveCopilotToken, CopilotService.saveCopilotToken)
ipcMain.handle(IpcChannel.Copilot_GetToken, CopilotService.getToken)
ipcMain.handle(IpcChannel.Copilot_Logout, CopilotService.logout)
ipcMain.handle(IpcChannel.Copilot_GetUser, CopilotService.getUser)
// Obsidian service
ipcMain.handle(IpcChannel.Obsidian_GetVaults, () => {
return obsidianVaultService.getVaults()
})
ipcMain.handle(IpcChannel.Obsidian_GetFiles, (_event, vaultName) => {
return obsidianVaultService.getFilesByVaultName(vaultName)
})
// nutstore
ipcMain.handle(IpcChannel.Nutstore_GetSsoUrl, NutstoreService.getNutstoreSSOUrl)
ipcMain.handle(IpcChannel.Nutstore_DecryptToken, (_, token: string) => NutstoreService.decryptToken(token))
ipcMain.handle(IpcChannel.Nutstore_GetDirectoryContents, (_, token: string, path: string) =>
NutstoreService.getDirectoryContents(token, path)
)
// search window
ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string) => {
await searchService.openSearchWindow(uid)
})
ipcMain.handle(IpcChannel.SearchWindow_Close, async (_, uid: string) => {
await searchService.closeSearchWindow(uid)
})
ipcMain.handle(IpcChannel.SearchWindow_OpenUrl, async (_, uid: string, url: string) => {
return await searchService.openUrlInSearchWindow(uid, url)
})
// webview
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
setOpenLinkExternal(webviewId, isExternal)
)
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
const webview = webContents.fromId(webviewId)
if (!webview) return
webview.session.setSpellCheckerEnabled(isEnable)
})
// store sync
storeSyncService.registerIpcHandler()
// selection assistant
SelectionService.registerIpcHandler()
ipcMain.handle(IpcChannel.App_QuoteToMain, (_, text: string) => windowService.quoteToMainWindow(text))
}