Files
ChatLab/electron/main/ipc/window.ts
T

300 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 窗口和文件系统操作 IPC 处理器
*/
import { ipcMain, app, dialog, clipboard, shell, nativeTheme } from 'electron'
import * as fs from 'fs/promises'
import type { IpcContext } from './types'
import { simulateUpdateDialog, manualCheckForUpdates } from '../update'
import { t } from '../i18n'
type AppWithQuitFlag = typeof app & { isQuiting?: boolean }
// 通过类型扩展记录应用退出意图,避免使用 @ts-ignore。
const appWithQuitFlag = app as AppWithQuitFlag
const REMOTE_CONFIG_ALLOWED_DOMAINS = ['chatlab.fun', '1app.top']
const REMOTE_CONFIG_TIMEOUT_MS = 8000
const REMOTE_CONFIG_MAX_BYTES = 1024 * 1024 // 1MB
function isAllowedRemoteConfigUrl(rawUrl: string): boolean {
let parsed: URL
try {
parsed = new URL(rawUrl)
} catch {
return false
}
if (parsed.protocol !== 'https:') return false
if (parsed.username || parsed.password) return false
if (parsed.port && parsed.port !== '443') return false
const hostname = parsed.hostname.toLowerCase()
return REMOTE_CONFIG_ALLOWED_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`))
}
/**
* 注册窗口和文件系统操作 IPC 处理器
*/
export function registerWindowHandlers(ctx: IpcContext): void {
const { win } = ctx
// ==================== 窗口操作 ====================
ipcMain.on('window-min', (ev) => {
ev.preventDefault()
win.minimize()
})
ipcMain.on('window-maxOrRestore', (ev) => {
const winSizeState = win.isMaximized()
if (winSizeState) {
win.restore()
} else {
win.maximize()
}
ev.reply('windowState', win.isMaximized())
})
ipcMain.on('window-restore', () => {
win.restore()
})
ipcMain.on('window-hide', () => {
win.hide()
})
ipcMain.on('window-close', () => {
win.close()
appWithQuitFlag.isQuiting = true
app.quit()
})
ipcMain.on('window-resize', (_, data) => {
if (data.resize) {
win.setResizable(true)
} else {
win.setSize(1180, 752)
win.setResizable(false)
}
})
ipcMain.on('open-devtools', () => {
win.webContents.openDevTools()
})
// 设置主题模式
ipcMain.on('window:setThemeSource', (_, mode: 'system' | 'light' | 'dark') => {
nativeTheme.themeSource = mode
// Windows 上动态更新 overlay 颜色以匹配主题
if (process.platform === 'win32' && win) {
const isDark = nativeTheme.shouldUseDarkColors
win.setTitleBarOverlay({
color: isDark ? '#111827' : '#f9fafb', // dark: gray-900, light: gray-50
symbolColor: isDark ? '#a1a1aa' : '#52525b', // dark: zinc-400, light: zinc-600
height: 32,
})
}
})
// ==================== 应用信息 ====================
ipcMain.handle('app:getVersion', () => {
return app.getVersion()
})
// 重启应用
ipcMain.handle('app:relaunch', () => {
app.relaunch()
app.quit()
})
// 获取远程配置(支持 JSON 和纯文本/Markdown
ipcMain.handle('app:fetchRemoteConfig', async (_, url: string) => {
const normalizedUrl = typeof url === 'string' ? url.trim() : ''
if (!isAllowedRemoteConfigUrl(normalizedUrl)) {
return { success: false, error: 'URL is not allowed' }
}
const abortController = new AbortController()
const timeout = setTimeout(() => abortController.abort(), REMOTE_CONFIG_TIMEOUT_MS)
try {
// 使用 manual 重定向模式,手动验证每个重定向目标
let currentUrl = normalizedUrl
let response = await fetch(currentUrl, {
signal: abortController.signal,
redirect: 'manual',
})
// 处理重定向链(最多跟随3次重定向,避免无限循环)
let redirectCount = 0
const maxRedirects = 3
while (response.status >= 300 && response.status < 400 && redirectCount < maxRedirects) {
redirectCount++
const location = response.headers.get('location')
if (!location) {
return { success: false, error: `Redirect response without location header (hop ${redirectCount})` }
}
// 构建完整的重定向 URL
const redirectUrl = new URL(location, currentUrl).href
if (!isAllowedRemoteConfigUrl(redirectUrl)) {
return { success: false, error: `Redirect URL is not allowed (hop ${redirectCount}): ${redirectUrl}` }
}
// 跟随重定向
currentUrl = redirectUrl
response = await fetch(currentUrl, {
signal: abortController.signal,
redirect: 'manual',
})
}
// 检查是否超过最大重定向次数(严格大于,允许恰好等于最大次数)
if (redirectCount > maxRedirects) {
return { success: false, error: `Too many redirects (exceeded ${maxRedirects})` }
}
// 验证最终响应的 URL
const finalUrl = response.url || currentUrl
if (!isAllowedRemoteConfigUrl(finalUrl)) {
return { success: false, error: 'Final URL is not allowed' }
}
const contentType = response.headers.get('content-type') || ''
const contentLength = Number(response.headers.get('content-length') || 0)
if (Number.isFinite(contentLength) && contentLength > REMOTE_CONFIG_MAX_BYTES) {
return { success: false, error: 'Response is too large' }
}
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}: ${response.statusText}` }
}
const buffer = Buffer.from(await response.arrayBuffer())
if (buffer.length > REMOTE_CONFIG_MAX_BYTES) {
return { success: false, error: 'Response is too large' }
}
// 根据 Content-Type 或 URL 后缀决定解析方式
const isJson = contentType.includes('application/json') || finalUrl.endsWith('.json')
if (isJson) {
const data = JSON.parse(buffer.toString('utf-8'))
return { success: true, data }
} else {
// 纯文本/Markdown 等其他格式
const data = buffer.toString('utf-8')
return { success: true, data }
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return { success: false, error: 'Request timeout' }
}
return { success: false, error: String(error) }
} finally {
clearTimeout(timeout)
}
})
// ==================== 更新检查 ====================
ipcMain.on('check-update', () => {
// 手动检查更新(即使是预发布版本也会提示)
manualCheckForUpdates()
})
// 模拟更新弹窗(仅开发模式使用)
ipcMain.on('simulate-update', () => {
if (!app.isPackaged) {
simulateUpdateDialog(win)
}
})
// ==================== 通用工具 ====================
ipcMain.handle('show-message', (event, args) => {
event.sender.send('show-message', args)
})
// 复制到剪贴板(文本)
ipcMain.handle('copyData', async (_, data) => {
try {
clipboard.writeText(data)
return true
} catch (error) {
console.error('Copy operation error:', error)
return false
}
})
// 复制图片到剪贴板(base64 data URL
ipcMain.handle('copyImage', async (_, dataUrl: string) => {
try {
// 从 data URL 中提取 base64 数据
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '')
const imageBuffer = Buffer.from(base64Data, 'base64')
// 使用 nativeImage 创建图片并写入剪贴板
const { nativeImage } = await import('electron')
const image = nativeImage.createFromBuffer(imageBuffer)
clipboard.writeImage(image)
return { success: true }
} catch (error) {
console.error('Image copy error:', error)
return { success: false, error: String(error) }
}
})
// ==================== 文件系统操作 ====================
// 选择文件夹
ipcMain.handle('selectDir', async (_, defaultPath = '') => {
try {
const { canceled, filePaths } = await dialog.showOpenDialog({
title: t('dialog.selectDirectory'),
defaultPath: defaultPath || app.getPath('documents'),
properties: ['openDirectory', 'createDirectory'],
buttonLabel: t('dialog.selectFolder'),
})
if (!canceled) {
return filePaths[0]
}
return null
} catch (err) {
console.error(t('dialog.selectFolderError'), err)
throw err
}
})
// 检查文件是否存在
ipcMain.handle('checkFileExist', async (_, filePath) => {
try {
await fs.access(filePath)
return true
} catch {
return false
}
})
// 在文件管理器中打开
ipcMain.handle('openInFolder', async (_, path) => {
try {
await fs.access(path)
await shell.showItemInFolder(path)
return true
} catch (error) {
console.error('Error opening directory:', error)
return false
}
})
// 显示打开对话框(通用)
ipcMain.handle('dialog:showOpenDialog', async (_, options) => {
try {
return await dialog.showOpenDialog(options)
} catch (error) {
console.error('Failed to show dialog:', error)
throw error
}
})
}