334 lines
10 KiB
TypeScript
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()
|
|
})
|
|
}
|