feat: build licensed desktop image processor

This commit is contained in:
2026-08-26 07:26:35 +08:00
parent 6ff3d71921
commit 1f31424015
32 changed files with 6515 additions and 2 deletions
+106
View File
@@ -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,
}
}
})
+294
View File
@@ -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,
}
}
+333
View File
@@ -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()
})
}
+170
View File
@@ -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(),
}
}
}
+70
View File
@@ -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 })
}
}
+15
View File
@@ -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/)
})
})
+15
View File
@@ -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,
}
}
+102
View File
@@ -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(/\/$/, '')
}
}
+36
View File
@@ -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)