feat: build licensed desktop image processor
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user