feat: build licensed desktop image processor
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import sharp from 'sharp'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { ProcessOptions } from '../../src/shared/types'
|
||||
import {
|
||||
ImageProcessor,
|
||||
processSingleImage,
|
||||
readSofHeight,
|
||||
} from './image-processor'
|
||||
|
||||
describe('image processing algorithm', () => {
|
||||
let temporaryDirectory: string
|
||||
let inputPath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'genuine-qingquwang-'))
|
||||
inputPath = path.join(temporaryDirectory, 'sample.jpg')
|
||||
await sharp({
|
||||
create: {
|
||||
width: 18,
|
||||
height: 12,
|
||||
channels: 3,
|
||||
background: { r: 23, g: 105, b: 224 },
|
||||
},
|
||||
}).jpeg({ quality: 90 }).toFile(inputPath)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(temporaryDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('writes a .file without EOI while restoring original SOF height', async () => {
|
||||
const outputPath = await processSingleImage(inputPath, options('file', 0))
|
||||
const output = await fs.readFile(outputPath)
|
||||
|
||||
expect(outputPath).toBe(path.join(temporaryDirectory, 'sample.file'))
|
||||
expect(readSofHeight(output)).toBe(12)
|
||||
expect(output.subarray(-2).equals(Buffer.from([0xff, 0xd9]))).toBe(false)
|
||||
})
|
||||
|
||||
it('supports .file.jpg and appends the configured random byte count', async () => {
|
||||
const baselinePath = await processSingleImage(inputPath, options('file', 0))
|
||||
const baselineSize = (await fs.stat(baselinePath)).size
|
||||
const outputPath = await processSingleImage(inputPath, options('file-jpg', 0.01))
|
||||
const outputSize = (await fs.stat(outputPath)).size
|
||||
|
||||
expect(outputPath).toBe(path.join(temporaryDirectory, 'sample.file.jpg'))
|
||||
expect(outputSize - baselineSize).toBe(Math.floor(0.01 * 1024 * 1024))
|
||||
})
|
||||
|
||||
it('discovers and converts supported non-JPEG inputs into JPEG delivery files', async () => {
|
||||
const source = sharp({
|
||||
create: {
|
||||
width: 18,
|
||||
height: 12,
|
||||
channels: 4,
|
||||
background: { r: 23, g: 105, b: 224, alpha: 0.5 },
|
||||
},
|
||||
})
|
||||
const inputs = [
|
||||
path.join(temporaryDirectory, 'sample.png'),
|
||||
path.join(temporaryDirectory, 'sample.webp'),
|
||||
path.join(temporaryDirectory, 'sample.gif'),
|
||||
path.join(temporaryDirectory, 'sample.tiff'),
|
||||
path.join(temporaryDirectory, 'sample.avif'),
|
||||
]
|
||||
await Promise.all([
|
||||
source.clone().png().toFile(inputs[0]),
|
||||
source.clone().webp().toFile(inputs[1]),
|
||||
source.clone().gif().toFile(inputs[2]),
|
||||
source.clone().tiff().toFile(inputs[3]),
|
||||
source.clone().avif().toFile(inputs[4]),
|
||||
fs.writeFile(path.join(temporaryDirectory, 'sample.svg'), '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="12"><rect width="18" height="12" fill="#1769e0"/></svg>'),
|
||||
fs.writeFile(path.join(temporaryDirectory, 'ignored.bmp'), 'not-supported'),
|
||||
])
|
||||
|
||||
const processor = new ImageProcessor()
|
||||
const inspected = await processor.inspect([temporaryDirectory])
|
||||
const inspectedNames = inspected.map((item) => item.name)
|
||||
expect(inspectedNames).toEqual([
|
||||
'sample.avif',
|
||||
'sample.gif',
|
||||
'sample.jpg',
|
||||
'sample.png',
|
||||
'sample.svg',
|
||||
'sample.tiff',
|
||||
'sample.webp',
|
||||
])
|
||||
|
||||
for (const input of [...inputs, path.join(temporaryDirectory, 'sample.svg')]) {
|
||||
const outputPath = await processSingleImage(input, options('file', 0))
|
||||
expect(readSofHeight(await fs.readFile(outputPath))).toBe(12)
|
||||
}
|
||||
})
|
||||
|
||||
function options(outputFormat: 'file' | 'file-jpg', junkSizeMb: number): ProcessOptions {
|
||||
return {
|
||||
paths: [inputPath],
|
||||
useSourceDirectory: true,
|
||||
outputFormat,
|
||||
junkSizeMb,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,294 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import sharp from 'sharp'
|
||||
import type {
|
||||
ImageFileItem,
|
||||
ProcessOptions,
|
||||
ProcessProgress,
|
||||
ProcessSummary,
|
||||
} from '../../src/shared/types'
|
||||
|
||||
export const IMAGE_INPUT_EXTENSIONS = [
|
||||
'jpg', 'jpeg', 'jpe', 'jfif',
|
||||
'png', 'webp', 'gif', 'tif', 'tiff', 'avif', 'svg',
|
||||
]
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(IMAGE_INPUT_EXTENSIONS.map((extension) => `.${extension}`))
|
||||
const SOF_MARKERS = new Set([
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7,
|
||||
0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
|
||||
])
|
||||
|
||||
export class ImageProcessor {
|
||||
private cancelled = false
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled = true
|
||||
}
|
||||
|
||||
async inspect(inputPaths: string[]): Promise<ImageFileItem[]> {
|
||||
const files = await collectImagePaths(inputPaths)
|
||||
return Promise.all(files.map(async (filePath) => {
|
||||
const stat = await fs.stat(filePath)
|
||||
try {
|
||||
const metadata = await sharp(filePath).metadata()
|
||||
return {
|
||||
id: fileId(filePath),
|
||||
path: filePath,
|
||||
name: path.basename(filePath),
|
||||
size: stat.size,
|
||||
width: metadata.width ?? null,
|
||||
height: metadata.height ?? null,
|
||||
status: 'pending' as const,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
id: fileId(filePath),
|
||||
path: filePath,
|
||||
name: path.basename(filePath),
|
||||
size: stat.size,
|
||||
width: null,
|
||||
height: null,
|
||||
status: 'failed' as const,
|
||||
error: error instanceof Error ? error.message : '无法读取图片',
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async process(
|
||||
options: ProcessOptions,
|
||||
onProgress: (progress: ProcessProgress) => void,
|
||||
): Promise<ProcessSummary> {
|
||||
validateOptions(options)
|
||||
this.cancelled = false
|
||||
let completed = 0
|
||||
let failed = 0
|
||||
|
||||
for (let index = 0; index < options.paths.length; index++) {
|
||||
const inputPath = options.paths[index]
|
||||
if (this.cancelled) {
|
||||
break
|
||||
}
|
||||
const item = await inspectSingle(inputPath)
|
||||
item.status = 'processing'
|
||||
onProgress(progressFor(index, options.paths.length, item, false))
|
||||
try {
|
||||
item.outputPath = await processSingleImage(inputPath, options, () => this.cancelled)
|
||||
item.status = this.cancelled ? 'cancelled' : 'completed'
|
||||
if (item.status === 'completed') {
|
||||
completed += 1
|
||||
}
|
||||
} catch (error) {
|
||||
item.status = this.cancelled ? 'cancelled' : 'failed'
|
||||
item.error = error instanceof Error ? error.message : '处理失败'
|
||||
if (!this.cancelled) {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
onProgress(progressFor(index + 1, options.paths.length, item, this.cancelled))
|
||||
}
|
||||
|
||||
return {
|
||||
total: options.paths.length,
|
||||
completed,
|
||||
failed,
|
||||
cancelled: this.cancelled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function processSingleImage(
|
||||
inputPath: string,
|
||||
options: ProcessOptions,
|
||||
isCancelled: () => boolean = () => false,
|
||||
): Promise<string> {
|
||||
const metadata = await sharp(inputPath, { page: 0, pages: 1 }).metadata()
|
||||
if (!metadata.width || !metadata.height || metadata.height <= 2) {
|
||||
throw new Error('图片尺寸过小,无法执行 AI 混淆处理')
|
||||
}
|
||||
|
||||
const encoded = await sharp(inputPath, { page: 0, pages: 1 })
|
||||
.flatten({ background: '#ffffff' })
|
||||
.extract({ left: 0, top: 0, width: metadata.width, height: metadata.height - 2 })
|
||||
.jpeg({ quality: 95 })
|
||||
.toBuffer()
|
||||
const heightRestored = restoreSofHeight(encoded, metadata.height)
|
||||
const withoutEoi = removeLastEoi(heightRestored)
|
||||
const outputPath = buildOutputPath(inputPath, options)
|
||||
const temporaryPath = `${outputPath}.${process.pid}.tmp`
|
||||
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true })
|
||||
try {
|
||||
await fs.writeFile(temporaryPath, withoutEoi)
|
||||
let remaining = Math.floor(options.junkSizeMb * 1024 * 1024)
|
||||
if (remaining > 0) {
|
||||
const handle = await fs.open(temporaryPath, 'a')
|
||||
try {
|
||||
while (remaining > 0) {
|
||||
if (isCancelled()) {
|
||||
throw new Error('处理已取消')
|
||||
}
|
||||
const chunkSize = Math.min(remaining, 1024 * 1024)
|
||||
await handle.write(randomBytes(chunkSize))
|
||||
remaining -= chunkSize
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
await fs.rm(outputPath, { force: true })
|
||||
await fs.rename(temporaryPath, outputPath)
|
||||
return outputPath
|
||||
} finally {
|
||||
await fs.rm(temporaryPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreSofHeight(buffer: Buffer, originalHeight: number): Buffer {
|
||||
const result = Buffer.from(buffer)
|
||||
let index = 0
|
||||
while (index < result.length - 1) {
|
||||
if (result[index] !== 0xff) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let markerIndex = index + 1
|
||||
while (markerIndex < result.length && result[markerIndex] === 0xff) {
|
||||
markerIndex += 1
|
||||
}
|
||||
const marker = result[markerIndex]
|
||||
if (SOF_MARKERS.has(marker)) {
|
||||
const heightOffset = markerIndex + 4
|
||||
if (heightOffset + 1 >= result.length) {
|
||||
throw new Error('JPEG SOF 高度字段不完整')
|
||||
}
|
||||
result[heightOffset] = (originalHeight >> 8) & 0xff
|
||||
result[heightOffset + 1] = originalHeight & 0xff
|
||||
return result
|
||||
}
|
||||
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
index = markerIndex + 1
|
||||
continue
|
||||
}
|
||||
if (markerIndex + 2 >= result.length) {
|
||||
break
|
||||
}
|
||||
const segmentLength = result.readUInt16BE(markerIndex + 1)
|
||||
if (segmentLength < 2) {
|
||||
break
|
||||
}
|
||||
index = markerIndex + 1 + segmentLength
|
||||
}
|
||||
throw new Error('未找到 JPEG SOF 图片尺寸段')
|
||||
}
|
||||
|
||||
export function removeLastEoi(buffer: Buffer): Buffer {
|
||||
for (let index = buffer.length - 1; index > 0; index--) {
|
||||
if (buffer[index - 1] === 0xff && buffer[index] === 0xd9) {
|
||||
return Buffer.concat([buffer.subarray(0, index - 1), buffer.subarray(index + 1)])
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
export function readSofHeight(buffer: Buffer): number | null {
|
||||
let index = 0
|
||||
while (index < buffer.length - 7) {
|
||||
if (buffer[index] === 0xff && SOF_MARKERS.has(buffer[index + 1])) {
|
||||
return buffer.readUInt16BE(index + 5)
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function collectImagePaths(inputPaths: string[]): Promise<string[]> {
|
||||
const result: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const stack = [...inputPaths]
|
||||
while (stack.length > 0) {
|
||||
const candidate = stack.pop()
|
||||
if (!candidate) continue
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.lstat(candidate)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (stat.isSymbolicLink()) continue
|
||||
if (stat.isDirectory()) {
|
||||
const entries = await fs.readdir(candidate, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isSymbolicLink()) {
|
||||
stack.push(path.join(candidate, entry.name))
|
||||
}
|
||||
}
|
||||
} else if (stat.isFile() && IMAGE_EXTENSIONS.has(path.extname(candidate).toLowerCase())) {
|
||||
const absolutePath = path.resolve(candidate)
|
||||
if (!seen.has(absolutePath)) {
|
||||
seen.add(absolutePath)
|
||||
result.push(absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
}
|
||||
|
||||
async function inspectSingle(filePath: string): Promise<ImageFileItem> {
|
||||
const [stat, metadata] = await Promise.all([
|
||||
fs.stat(filePath),
|
||||
sharp(filePath).metadata(),
|
||||
])
|
||||
return {
|
||||
id: fileId(filePath),
|
||||
path: filePath,
|
||||
name: path.basename(filePath),
|
||||
size: stat.size,
|
||||
width: metadata.width ?? null,
|
||||
height: metadata.height ?? null,
|
||||
status: 'pending',
|
||||
}
|
||||
}
|
||||
|
||||
function buildOutputPath(inputPath: string, options: ProcessOptions): string {
|
||||
const outputDirectory = options.useSourceDirectory
|
||||
? path.dirname(inputPath)
|
||||
: path.resolve(options.outputDirectory || '')
|
||||
const basename = path.basename(inputPath, path.extname(inputPath))
|
||||
const suffix = options.outputFormat === 'file-jpg' ? '.file.jpg' : '.file'
|
||||
return path.join(outputDirectory, `${basename}${suffix}`)
|
||||
}
|
||||
|
||||
function validateOptions(options: ProcessOptions): void {
|
||||
if (!options.paths.length) {
|
||||
throw new Error('请先添加需要处理的图片')
|
||||
}
|
||||
if (!options.useSourceDirectory && !options.outputDirectory) {
|
||||
throw new Error('请选择输出目录')
|
||||
}
|
||||
if (!Number.isFinite(options.junkSizeMb)
|
||||
|| options.junkSizeMb < 0 || options.junkSizeMb > 100) {
|
||||
throw new Error('追加数据大小必须在 0 到 100 MB 之间')
|
||||
}
|
||||
}
|
||||
|
||||
function fileId(filePath: string): string {
|
||||
return createHash('sha1').update(filePath).digest('hex')
|
||||
}
|
||||
|
||||
function progressFor(
|
||||
current: number,
|
||||
total: number,
|
||||
item: ImageFileItem,
|
||||
cancelled: boolean,
|
||||
): ProcessProgress {
|
||||
return {
|
||||
current,
|
||||
total,
|
||||
percent: total === 0 ? 0 : Math.round((current / total) * 100),
|
||||
item,
|
||||
cancelled,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { app } from 'electron'
|
||||
import { createHash } from 'node:crypto'
|
||||
import os from 'node:os'
|
||||
import { machineIdSync } from 'node-machine-id'
|
||||
import type {
|
||||
AppInfo,
|
||||
LicenseSession,
|
||||
LicenseViewState,
|
||||
VersionInfo,
|
||||
} from '../../src/shared/types'
|
||||
import { LicenseStore, type StoredLicense } from './license-store'
|
||||
import {
|
||||
DesktopServerApi,
|
||||
type DevicePayload,
|
||||
ServerApiError,
|
||||
} from './server-api'
|
||||
|
||||
const OFFLINE_GRACE_MS = 72 * 60 * 60 * 1000
|
||||
|
||||
export class LicenseManager {
|
||||
private readonly store = new LicenseStore()
|
||||
private readonly server = new DesktopServerApi()
|
||||
|
||||
getAppInfo(): AppInfo {
|
||||
return {
|
||||
name: '正版情趣王',
|
||||
version: app.getVersion(),
|
||||
platform: process.platform,
|
||||
deviceName: os.hostname(),
|
||||
}
|
||||
}
|
||||
|
||||
async getState(): Promise<LicenseViewState> {
|
||||
const stored = await this.store.load()
|
||||
if (!stored) {
|
||||
return {
|
||||
status: 'unlicensed',
|
||||
message: '输入激活码后即可开始使用',
|
||||
offline: false,
|
||||
}
|
||||
}
|
||||
return this.validateStored(stored)
|
||||
}
|
||||
|
||||
async activate(activationCode: string): Promise<LicenseViewState> {
|
||||
try {
|
||||
const session = await this.server.activate(activationCode, this.devicePayload())
|
||||
if (!session.token) {
|
||||
throw new ServerApiError('INVALID_RESPONSE', '授权服务未返回设备凭据')
|
||||
}
|
||||
await this.persistSession(session)
|
||||
return {
|
||||
status: 'active',
|
||||
message: '设备已激活',
|
||||
offline: false,
|
||||
session,
|
||||
version: session.version,
|
||||
}
|
||||
} catch (error) {
|
||||
return this.errorState(error)
|
||||
}
|
||||
}
|
||||
|
||||
async validate(): Promise<LicenseViewState> {
|
||||
const stored = await this.store.load()
|
||||
if (!stored) {
|
||||
return {
|
||||
status: 'unlicensed',
|
||||
message: '当前设备尚未激活',
|
||||
offline: false,
|
||||
}
|
||||
}
|
||||
return this.validateStored(stored)
|
||||
}
|
||||
|
||||
async clear(): Promise<LicenseViewState> {
|
||||
await this.store.clear()
|
||||
return {
|
||||
status: 'unlicensed',
|
||||
message: '本机授权凭据已清除',
|
||||
offline: false,
|
||||
}
|
||||
}
|
||||
|
||||
checkVersion(): Promise<VersionInfo> {
|
||||
return this.server.checkVersion(app.getVersion())
|
||||
}
|
||||
|
||||
private async validateStored(stored: StoredLicense): Promise<LicenseViewState> {
|
||||
try {
|
||||
const session = await this.server.login(stored.token, this.devicePayload())
|
||||
session.token = stored.token
|
||||
await this.persistSession(session)
|
||||
return {
|
||||
status: 'active',
|
||||
message: '授权有效',
|
||||
offline: false,
|
||||
session,
|
||||
version: session.version,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ServerApiError && error.code.startsWith('NETWORK_')
|
||||
&& this.canUseOffline(stored)) {
|
||||
const session: LicenseSession = {
|
||||
licenseCodeMask: stored.licenseCodeMask,
|
||||
deviceMask: stored.deviceMask,
|
||||
expiresAt: stored.expiresAt,
|
||||
maxDevices: stored.maxDevices,
|
||||
}
|
||||
return {
|
||||
status: 'active',
|
||||
message: '当前处于离线授权模式,请在 72 小时内联网校验',
|
||||
offline: true,
|
||||
session,
|
||||
}
|
||||
}
|
||||
return this.errorState(error)
|
||||
}
|
||||
}
|
||||
|
||||
private async persistSession(session: LicenseSession): Promise<void> {
|
||||
if (!session.token) {
|
||||
throw new Error('缺少设备授权凭据')
|
||||
}
|
||||
await this.store.save({
|
||||
token: session.token,
|
||||
licenseCodeMask: session.licenseCodeMask,
|
||||
deviceMask: session.deviceMask,
|
||||
expiresAt: session.expiresAt,
|
||||
maxDevices: session.maxDevices,
|
||||
lastVerifiedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
private canUseOffline(stored: StoredLicense): boolean {
|
||||
const lastVerifiedAt = new Date(stored.lastVerifiedAt).getTime()
|
||||
const expiresAt = stored.expiresAt ? new Date(stored.expiresAt).getTime() : Number.POSITIVE_INFINITY
|
||||
return Number.isFinite(lastVerifiedAt)
|
||||
&& Date.now() - lastVerifiedAt <= OFFLINE_GRACE_MS
|
||||
&& Date.now() < expiresAt
|
||||
}
|
||||
|
||||
private errorState(error: unknown): LicenseViewState {
|
||||
if (error instanceof ServerApiError) {
|
||||
return {
|
||||
status: error.code.startsWith('NETWORK_') ? 'network-error' : 'invalid',
|
||||
message: error.message,
|
||||
offline: false,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'invalid',
|
||||
message: error instanceof Error ? error.message : '授权校验失败',
|
||||
offline: false,
|
||||
}
|
||||
}
|
||||
|
||||
private devicePayload(): DevicePayload {
|
||||
const deviceId = createHash('sha256')
|
||||
.update(`正版情趣王:${machineIdSync()}`)
|
||||
.digest('hex')
|
||||
return {
|
||||
deviceId,
|
||||
deviceName: os.hostname(),
|
||||
osName: `${os.type()} ${os.arch()}`,
|
||||
osVersion: os.release(),
|
||||
appVersion: app.getVersion(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export interface StoredLicense {
|
||||
token: string
|
||||
licenseCodeMask: string
|
||||
deviceMask: string
|
||||
expiresAt?: string
|
||||
maxDevices: number
|
||||
lastVerifiedAt: string
|
||||
}
|
||||
|
||||
interface StoredLicenseFile {
|
||||
encryptedToken: string
|
||||
licenseCodeMask: string
|
||||
deviceMask: string
|
||||
expiresAt?: string
|
||||
maxDevices: number
|
||||
lastVerifiedAt: string
|
||||
}
|
||||
|
||||
export class LicenseStore {
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'license.json')
|
||||
}
|
||||
|
||||
async load(): Promise<StoredLicense | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, 'utf8')
|
||||
const stored = JSON.parse(raw) as StoredLicenseFile
|
||||
const decrypted = await safeStorage.decryptStringAsync(
|
||||
Buffer.from(stored.encryptedToken, 'base64'),
|
||||
)
|
||||
return {
|
||||
token: decrypted.result,
|
||||
licenseCodeMask: stored.licenseCodeMask,
|
||||
deviceMask: stored.deviceMask,
|
||||
expiresAt: stored.expiresAt,
|
||||
maxDevices: stored.maxDevices,
|
||||
lastVerifiedAt: stored.lastVerifiedAt,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async save(license: StoredLicense): Promise<void> {
|
||||
if (!(await safeStorage.isAsyncEncryptionAvailable())) {
|
||||
throw new Error('系统安全凭据存储不可用')
|
||||
}
|
||||
const encryptedToken = await safeStorage.encryptStringAsync(license.token)
|
||||
const stored: StoredLicenseFile = {
|
||||
encryptedToken: encryptedToken.toString('base64'),
|
||||
licenseCodeMask: license.licenseCodeMask,
|
||||
deviceMask: license.deviceMask,
|
||||
expiresAt: license.expiresAt,
|
||||
maxDevices: license.maxDevices,
|
||||
lastVerifiedAt: license.lastVerifiedAt,
|
||||
}
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true })
|
||||
const temporaryPath = `${this.filePath}.tmp`
|
||||
await fs.writeFile(temporaryPath, JSON.stringify(stored), { mode: 0o600 })
|
||||
await fs.rename(temporaryPath, this.filePath)
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await fs.rm(this.filePath, { force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildDesktopRequestHeaders } from './server-api-headers'
|
||||
|
||||
describe('desktop server request headers', () => {
|
||||
it('builds a Fetch-compatible ASCII client header', () => {
|
||||
const headers = buildDesktopRequestHeaders('1.0.0')
|
||||
|
||||
expect(headers['X-Desktop-Client']).toBe('genuine-qingquwang/1.0.0')
|
||||
expect(() => new Headers(headers)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects non-ASCII version labels before making a request', () => {
|
||||
expect(() => buildDesktopRequestHeaders('版本-1.0.0')).toThrow(/ASCII/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
const DESKTOP_CLIENT_ID = 'genuine-qingquwang'
|
||||
const VISIBLE_ASCII = /^[\x20-\x7E]+$/
|
||||
|
||||
export function buildDesktopRequestHeaders(version: string): Record<string, string> {
|
||||
const clientHeader = `${DESKTOP_CLIENT_ID}/${version}`
|
||||
// Fetch 请求头必须是 ByteString,禁止中文等非 ASCII 字符在发请求前触发异常。
|
||||
if (!VISIBLE_ASCII.test(clientHeader)) {
|
||||
throw new Error('桌面客户端版本标识必须使用 ASCII 字符')
|
||||
}
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Desktop-Client': clientHeader,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { app, net } from 'electron'
|
||||
import type {
|
||||
LicenseSession,
|
||||
VersionInfo,
|
||||
} from '../../src/shared/types'
|
||||
import { buildDesktopRequestHeaders } from './server-api-headers'
|
||||
|
||||
interface ApiResult<T> {
|
||||
code: string
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface DevicePayload {
|
||||
deviceId: string
|
||||
deviceName: string
|
||||
osName: string
|
||||
osVersion: string
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
export class ServerApiError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ServerApiError'
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'https://apimall.v2.jxxyk.com/api'
|
||||
|
||||
export class DesktopServerApi {
|
||||
private readonly baseUrl: string
|
||||
|
||||
constructor(baseUrl = process.env.IMAGE_TOOL_API_BASE_URL || DEFAULT_API_BASE_URL) {
|
||||
this.baseUrl = this.validateBaseUrl(baseUrl)
|
||||
}
|
||||
|
||||
activate(activationCode: string, device: DevicePayload): Promise<LicenseSession> {
|
||||
return this.post<LicenseSession>('/desktop/activate', {
|
||||
activationCode,
|
||||
...device,
|
||||
})
|
||||
}
|
||||
|
||||
login(token: string, device: DevicePayload): Promise<LicenseSession> {
|
||||
return this.post<LicenseSession>('/desktop/login', {
|
||||
token,
|
||||
...device,
|
||||
})
|
||||
}
|
||||
|
||||
checkVersion(currentVersion: string): Promise<VersionInfo> {
|
||||
return this.post<VersionInfo>('/desktop/version/check', {
|
||||
currentVersion,
|
||||
platform: 'windows',
|
||||
})
|
||||
}
|
||||
|
||||
private async post<T>(pathname: string, body: unknown): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000)
|
||||
try {
|
||||
const response = await net.fetch(`${this.baseUrl}${pathname}`, {
|
||||
method: 'POST',
|
||||
headers: buildDesktopRequestHeaders(app.getVersion()),
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new ServerApiError(String(response.status), `授权服务暂时不可用(${response.status})`)
|
||||
}
|
||||
const result = (await response.json()) as ApiResult<T>
|
||||
if (result.code !== '0') {
|
||||
throw new ServerApiError(result.code, result.msg || '授权校验失败')
|
||||
}
|
||||
return result.data
|
||||
} catch (error) {
|
||||
if (error instanceof ServerApiError) {
|
||||
throw error
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new ServerApiError('NETWORK_TIMEOUT', '连接授权服务超时,请检查网络后重试')
|
||||
}
|
||||
throw new ServerApiError('NETWORK_ERROR', '无法连接授权服务,请检查网络后重试')
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
private validateBaseUrl(value: string): string {
|
||||
const url = new URL(value.trim())
|
||||
const localDevelopment = url.protocol === 'http:'
|
||||
&& ['127.0.0.1', 'localhost'].includes(url.hostname)
|
||||
if (url.protocol !== 'https:' && !(localDevelopment && !app.isPackaged)) {
|
||||
throw new Error('生产授权服务必须使用 HTTPS')
|
||||
}
|
||||
return url.toString().replace(/\/$/, '')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||
import type {
|
||||
DesktopBridge,
|
||||
ProcessProgress,
|
||||
} from '../../src/shared/types'
|
||||
|
||||
const bridge: DesktopBridge = {
|
||||
getAppInfo: () => ipcRenderer.invoke('app:info'),
|
||||
getLicenseState: () => ipcRenderer.invoke('license:state'),
|
||||
activate: (activationCode) => ipcRenderer.invoke('license:activate', activationCode),
|
||||
validateLicense: () => ipcRenderer.invoke('license:validate'),
|
||||
clearLicense: () => ipcRenderer.invoke('license:clear'),
|
||||
checkVersion: () => ipcRenderer.invoke('license:check-version'),
|
||||
downloadUpdate: (downloadUrl, latestVersion) => ipcRenderer.invoke(
|
||||
'license:download-update',
|
||||
downloadUrl,
|
||||
latestVersion,
|
||||
),
|
||||
selectImages: () => ipcRenderer.invoke('images:select-files'),
|
||||
selectFolder: () => ipcRenderer.invoke('images:select-folder'),
|
||||
selectOutputDirectory: () => ipcRenderer.invoke('images:select-output'),
|
||||
pathsForFiles: (files) => files
|
||||
.map((file) => webUtils.getPathForFile(file))
|
||||
.filter(Boolean),
|
||||
inspectImages: (paths) => ipcRenderer.invoke('images:inspect', paths),
|
||||
processImages: (options) => ipcRenderer.invoke('images:process', options),
|
||||
cancelProcessing: () => ipcRenderer.invoke('images:cancel'),
|
||||
revealItem: (filePath) => ipcRenderer.invoke('images:reveal', filePath),
|
||||
onProcessProgress: (callback) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, progress: ProcessProgress) => callback(progress)
|
||||
ipcRenderer.on('images:progress', listener)
|
||||
return () => ipcRenderer.removeListener('images:progress', listener)
|
||||
},
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('desktop', bridge)
|
||||
Reference in New Issue
Block a user