Files
image-processing/electron/main/index.ts
T

334 lines
10 KiB
TypeScript

import {
app,
BrowserWindow,
dialog,
ipcMain,
session,
shell,
} from 'electron'
import type {
DownloadItem,
Event as ElectronEvent,
IpcMainInvokeEvent,
WebContents,
} from 'electron'
import { existsSync } from 'node:fs'
import path from 'node:path'
import type { ProcessOptions } from '../../src/shared/types'
import { IMAGE_INPUT_EXTENSIONS, ImageProcessor } from './image-processor'
import { LicenseManager } from './license-manager'
app.setName('正版情趣王')
app.enableSandbox()
const singleInstance = app.requestSingleInstanceLock()
if (!singleInstance) {
app.quit()
}
let mainWindow: BrowserWindow | null = null
const imageProcessor = new ImageProcessor()
const licenseManager = new LicenseManager()
const UPDATE_INSTALLER_EXTENSIONS = new Set(['.exe', '.msi'])
function createWindow(): void {
const preload = path.join(__dirname, '../preload/index.js')
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: 1360,
height: 860,
minWidth: 1120,
minHeight: 720,
show: false,
backgroundColor: '#F4F7FB',
title: '正版情趣王',
titleBarStyle: 'hidden',
webPreferences: {
preload,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
}
if (process.platform !== 'darwin') {
windowOptions.titleBarOverlay = {
color: '#F7F9FC',
symbolColor: '#596273',
height: 48,
}
}
mainWindow = new BrowserWindow(windowOptions)
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
mainWindow.webContents.on('will-navigate', (event, targetUrl) => {
if (targetUrl !== mainWindow?.webContents.getURL()) {
event.preventDefault()
}
})
mainWindow.once('ready-to-show', () => mainWindow?.show())
const devServerUrl = process.env.VITE_DEV_SERVER_URL
if (devServerUrl) {
void mainWindow.loadURL(devServerUrl)
} else {
void mainWindow.loadFile(path.join(__dirname, '../../../dist/index.html'))
}
}
function registerIpcHandlers(): void {
ipcMain.handle('app:info', (event) => {
assertTrustedSender(event)
return licenseManager.getAppInfo()
})
ipcMain.handle('license:state', (event) => {
assertTrustedSender(event)
return licenseManager.getState()
})
ipcMain.handle('license:activate', (event, activationCode: unknown) => {
assertTrustedSender(event)
if (typeof activationCode !== 'string' || activationCode.length > 64) {
throw new Error('激活码格式不正确')
}
return licenseManager.activate(activationCode)
})
ipcMain.handle('license:validate', (event) => {
assertTrustedSender(event)
return licenseManager.validate()
})
ipcMain.handle('license:clear', (event) => {
assertTrustedSender(event)
return licenseManager.clear()
})
ipcMain.handle('license:check-version', (event) => {
assertTrustedSender(event)
return licenseManager.checkVersion()
})
ipcMain.handle('license:download-update', async (
event,
downloadUrl: unknown,
latestVersion: unknown,
) => {
assertTrustedSender(event)
const url = validateUpdateUrl(downloadUrl)
const savePath = await downloadUpdateInstaller(event.sender, url, latestVersion)
const choice = await dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '更新已下载',
message: '新版安装包下载完成',
detail: '安装包已保存到:\n' + savePath + '\n\n点击“立即安装”继续升级,安装程序可能会提示关闭当前软件。',
buttons: ['立即安装', '打开文件夹', '稍后安装'],
defaultId: 0,
cancelId: 2,
noLink: true,
})
if (choice.response === 0) {
const openError = await shell.openPath(savePath)
if (openError) {
throw new Error('无法启动安装程序:' + openError)
}
} else if (choice.response === 1) {
shell.showItemInFolder(savePath)
}
})
ipcMain.handle('images:select-files', async (event) => {
assertTrustedSender(event)
const result = await dialog.showOpenDialog(mainWindow!, {
title: '选择图片',
properties: ['openFile', 'multiSelections'],
filters: [{ name: '支持的图片', extensions: IMAGE_INPUT_EXTENSIONS }],
})
return result.canceled ? [] : result.filePaths
})
ipcMain.handle('images:select-folder', async (event) => {
assertTrustedSender(event)
const result = await dialog.showOpenDialog(mainWindow!, {
title: '选择图片文件夹',
properties: ['openDirectory'],
})
return result.canceled ? [] : result.filePaths
})
ipcMain.handle('images:select-output', async (event) => {
assertTrustedSender(event)
const result = await dialog.showOpenDialog(mainWindow!, {
title: '选择输出目录',
properties: ['openDirectory', 'createDirectory'],
})
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('images:inspect', (event, paths: unknown) => {
assertTrustedSender(event)
return imageProcessor.inspect(validatePaths(paths))
})
ipcMain.handle('images:process', (event, options: ProcessOptions) => {
assertTrustedSender(event)
const validated: ProcessOptions = {
...options,
paths: validatePaths(options?.paths),
}
return imageProcessor.process(validated, (progress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('images:progress', progress)
}
})
})
ipcMain.handle('images:cancel', (event) => {
assertTrustedSender(event)
imageProcessor.cancel()
})
ipcMain.handle('images:reveal', (event, filePath: unknown) => {
assertTrustedSender(event)
if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) {
throw new Error('文件路径无效')
}
shell.showItemInFolder(filePath)
})
}
function validateUpdateUrl(value: unknown): URL {
if (typeof value !== 'string') {
throw new Error('下载地址无效')
}
const url = new URL(value)
if (url.protocol !== 'https:') {
throw new Error('安装包必须通过 HTTPS 下载')
}
const extension = path.extname(decodeUrlPathname(url)).toLowerCase()
if (!UPDATE_INSTALLER_EXTENSIONS.has(extension)) {
throw new Error('下载地址不是受支持的 Windows 安装包')
}
return url
}
function decodeUrlPathname(url: URL): string {
try {
return decodeURIComponent(url.pathname)
} catch {
throw new Error('安装包下载地址格式不正确')
}
}
function installerFilename(url: URL, latestVersion: unknown): string {
const extension = path.extname(decodeUrlPathname(url)).toLowerCase()
const version = typeof latestVersion === 'string'
? latestVersion.trim().replace(/^v/i, '').replace(/[^0-9A-Za-z._-]/g, '').slice(0, 32)
: ''
return version
? '正版情趣王-Setup-' + version + extension
: '正版情趣王-Setup' + extension
}
function availableDownloadPath(filename: string): string {
const downloadsDirectory = app.getPath('downloads')
const extension = path.extname(filename)
const basename = path.basename(filename, extension)
let candidate = path.join(downloadsDirectory, filename)
for (let index = 2; existsSync(candidate); index += 1) {
candidate = path.join(downloadsDirectory, basename + '-' + index + extension)
}
return candidate
}
function downloadUpdateInstaller(
sender: WebContents,
url: URL,
latestVersion: unknown,
): Promise<string> {
const savePath = availableDownloadPath(installerFilename(url, latestVersion))
const browserSession = sender.session
return new Promise((resolve, reject) => {
let downloadItem: DownloadItem | null = null
let settled = false
const cleanup = (): void => {
clearTimeout(timeout)
browserSession.removeListener('will-download', handleDownload)
}
const finish = (error?: Error): void => {
if (settled) return
settled = true
cleanup()
if (error) reject(error)
else resolve(savePath)
}
const handleDownload = (
_event: ElectronEvent,
item: DownloadItem,
source: WebContents,
): void => {
if (source !== sender) return
downloadItem = item
browserSession.removeListener('will-download', handleDownload)
item.setSavePath(savePath)
item.once('done', (_doneEvent, state) => {
if (state === 'completed') {
finish()
} else {
finish(new Error(state === 'cancelled' ? '安装包下载已取消' : '安装包下载中断,请重试'))
}
})
}
const timeout = setTimeout(() => {
downloadItem?.cancel()
finish(new Error('安装包下载超时,请检查网络后重试'))
}, 30 * 60 * 1000)
browserSession.on('will-download', handleDownload)
try {
// 由 Electron 直接下载,避免依赖 Windows 的 HTTPS 默认应用关联。
sender.downloadURL(url.toString())
} catch (error) {
finish(error instanceof Error ? error : new Error('无法开始下载安装包'))
}
})
}
function assertTrustedSender(event: IpcMainInvokeEvent): void {
const senderUrl = event.senderFrame?.url || ''
if (senderUrl.startsWith('file://')) {
return
}
if (!app.isPackaged && senderUrl.startsWith('http://127.0.0.1:5173')) {
return
}
throw new Error('拒绝来自非可信页面的请求')
}
function validatePaths(value: unknown): string[] {
if (!Array.isArray(value) || value.length > 10_000) {
throw new Error('文件列表无效')
}
return value.map((item) => {
if (typeof item !== 'string' || !path.isAbsolute(item) || item.length > 4096) {
throw new Error('文件路径无效')
}
return item
})
}
if (singleInstance) {
app.whenReady().then(() => {
app.setAppUserModelId('com.jxxyk.genuineqingquwang')
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false)
})
registerIpcHandlers()
createWindow()
})
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
}