mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
Remove the one-time close prompt and remembered-choice path so Windows close behavior is always explicit and predictable. New installations now minimize to the system tray by default, while users can still choose to quit from settings.
344 lines
10 KiB
TypeScript
344 lines
10 KiB
TypeScript
/**
|
||
* 窗口和文件系统操作 IPC 处理器
|
||
*/
|
||
|
||
import { ipcMain, app, dialog, clipboard, shell, nativeTheme } from 'electron'
|
||
import * as fs from 'fs/promises'
|
||
import { loadConfig, setConfigField } from '@openchatlab/config'
|
||
import type { DesktopCloseBehavior } from '@openchatlab/shared-types'
|
||
import type { IpcContext } from './types'
|
||
import { simulateUpdateDialog, manualCheckForUpdates } from '../update/manager'
|
||
import { t } from '../i18n'
|
||
import {
|
||
applyCurrentTitleBarOverlay,
|
||
applyTitleBarOverlayColor,
|
||
resetCurrentTitleBarOverlayColor,
|
||
} from '../window/titlebar'
|
||
import { getDesktopAppVersion } from '../runtime/compat'
|
||
import { requestAppQuit } from '../window/main-window'
|
||
import { destroyWindowsTray } from '../window/windows-tray'
|
||
|
||
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', () => {
|
||
requestAppQuit()
|
||
})
|
||
|
||
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) {
|
||
resetCurrentTitleBarOverlayColor()
|
||
applyCurrentTitleBarOverlay(win, nativeTheme.shouldUseDarkColors)
|
||
}
|
||
})
|
||
|
||
ipcMain.on('window:setTitleBarOverlayColor', (_, color: string) => {
|
||
if (!/^#[0-9a-f]{6}$/i.test(color)) return
|
||
|
||
if (process.platform === 'win32' && win) {
|
||
applyTitleBarOverlayColor(win, color)
|
||
}
|
||
})
|
||
|
||
// ==================== 应用信息 ====================
|
||
ipcMain.handle('app:getVersion', () => {
|
||
return getDesktopAppVersion(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.handle('app:getOpenAtLogin', () => {
|
||
if (!app.isPackaged) return false
|
||
const { openAtLogin } = app.getLoginItemSettings()
|
||
return openAtLogin
|
||
})
|
||
|
||
ipcMain.handle('app:setOpenAtLogin', (_, enabled: boolean) => {
|
||
if (!app.isPackaged) return { success: false, error: 'Not available in dev mode' }
|
||
try {
|
||
app.setLoginItemSettings({ openAtLogin: enabled })
|
||
return { success: true }
|
||
} catch (error) {
|
||
return { success: false, error: String(error) }
|
||
}
|
||
})
|
||
|
||
// ==================== Windows 关闭行为 ====================
|
||
ipcMain.handle('app:getDesktopCloseBehavior', () => {
|
||
return loadConfig().desktop.close_behavior
|
||
})
|
||
|
||
ipcMain.handle('app:setDesktopCloseBehavior', (_, behavior: DesktopCloseBehavior) => {
|
||
if (behavior !== 'background' && behavior !== 'quit') {
|
||
return { success: false, error: 'Unsupported desktop close behavior' }
|
||
}
|
||
|
||
try {
|
||
setConfigField('desktop.close_behavior', behavior)
|
||
if (behavior !== 'background') destroyWindowsTray()
|
||
return { success: true }
|
||
} catch (error) {
|
||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||
}
|
||
})
|
||
|
||
// ==================== 更新检查 ====================
|
||
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
|
||
}
|
||
})
|
||
}
|