优化一些bug
This commit is contained in:
+16
-3
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, globalShortcut, screen } from 'electron'
|
||||
import { app, BrowserWindow, dialog, globalShortcut, screen } from 'electron'
|
||||
import { join } from 'path'
|
||||
|
||||
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
|
||||
@@ -6,6 +6,7 @@ app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
|
||||
import log from 'electron-log'
|
||||
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
|
||||
import { setupNativeWorkingDir } from './services/native-path'
|
||||
import { configStore } from './services/config-store'
|
||||
|
||||
suppressKnownDllStderr()
|
||||
import { registerIpcHandlers, handleBeforeQuit } from './ipc/register-handlers'
|
||||
@@ -16,7 +17,7 @@ let mainWindow: BrowserWindow | null = null
|
||||
|
||||
const MIN_CONTENT_WIDTH = 960
|
||||
|
||||
/** 默认内容区:约 85% 工作区宽,高 = 宽×360/720 */
|
||||
/** 默认内容区:约 85% 工作区宽,高 720:360 */
|
||||
function getDefaultWindowSize(): { width: number; height: number } {
|
||||
const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize
|
||||
let w = Math.max(1280, Math.min(Math.floor(sw * 0.85), 1600))
|
||||
@@ -67,7 +68,7 @@ function createWindow(): void {
|
||||
}
|
||||
})
|
||||
|
||||
// 内容区锁定 720:360,与 useScale(scale = innerWidth/720) 一致
|
||||
// 内容区 720:360,与 useScale 一致
|
||||
mainWindow.on('resize', () => {
|
||||
if (!mainWindow) return
|
||||
const [cw, ch] = mainWindow.getContentSize()
|
||||
@@ -90,6 +91,13 @@ function createWindow(): void {
|
||||
|
||||
app.whenReady().then(() => {
|
||||
try {
|
||||
if (app.isPackaged) {
|
||||
configStore.set('skipDllInit', false)
|
||||
} else if (process.argv.includes('--skip-dll-init')) {
|
||||
configStore.set('skipDllInit', true)
|
||||
log.warn('skipDllInit enabled by argv: --skip-dll-init (dev only)')
|
||||
}
|
||||
|
||||
setupNativeWorkingDir()
|
||||
registerIpcHandlers()
|
||||
createWindow()
|
||||
@@ -100,7 +108,12 @@ app.whenReady().then(() => {
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
log.error('startup failed', e)
|
||||
dialog.showErrorBox(
|
||||
'启动失败',
|
||||
`${msg}\n\n请确认已完整解压安装包,且 resources\\native 下 7 个 dll 齐全。`
|
||||
)
|
||||
app.quit()
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
startUsbPoll,
|
||||
stopAllPolls,
|
||||
stopJobPoll,
|
||||
stopUsbPoll
|
||||
stopUsbPoll,
|
||||
getPollMainWindow
|
||||
} from '../services/poll-manager'
|
||||
import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size'
|
||||
import { parseSoonTemplate } from '../utils/parse-soon'
|
||||
import {
|
||||
dllAdminJobCancel,
|
||||
dllCopyFromUsb,
|
||||
@@ -34,12 +37,18 @@ function fail(code: number, message: string) {
|
||||
return { ok: false as const, code, message }
|
||||
}
|
||||
|
||||
let dllInitAttempted = false
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
ipcMain.handle('dll:init', (_e, params) => {
|
||||
if (dllInitAttempted) {
|
||||
return fail(CS_FAIL, '请勿重复初始化,请完全退出应用后重新启动再试')
|
||||
}
|
||||
stopAllPolls()
|
||||
try {
|
||||
const sharedDir = params?.sharedDir || (configStore.get('sharedDir') as string)
|
||||
fs.mkdirSync(sharedDir, { recursive: true })
|
||||
log.info(`SAPI_Init starting, sharedDir=${sharedDir}`)
|
||||
const code = dllInit({
|
||||
sharedDir,
|
||||
keepCombinedImage: params?.keepCombinedImage,
|
||||
@@ -50,17 +59,16 @@ export function registerIpcHandlers(): void {
|
||||
logLevel: params?.logLevel,
|
||||
outBack: params?.outBack
|
||||
})
|
||||
if (code === CS_OK) {
|
||||
mainAppState.initialized = true
|
||||
configStore.set('sharedDir', sharedDir)
|
||||
return ok({ printerDetected: true })
|
||||
}
|
||||
log.info(`SAPI_Init finished, code=${code}`)
|
||||
dllInitAttempted = true
|
||||
mainAppState.initialized = true
|
||||
configStore.set('sharedDir', sharedDir)
|
||||
if (code === CS_OK) {
|
||||
return ok()
|
||||
}
|
||||
log.warn(`SAPI_Init returned ${code}; UI ready, printer ops may fail until device connected`)
|
||||
return ok({
|
||||
printerDetected: false,
|
||||
warning: '打印机未连接或驱动未就绪,界面可浏览,业务操作需接真机后重试 Init'
|
||||
warning: '打印机未连接或驱动未就绪,界面可浏览,接好设备后可在设置中重试 Init'
|
||||
})
|
||||
} catch (err) {
|
||||
mainAppState.initialized = false
|
||||
@@ -129,7 +137,7 @@ export function registerIpcHandlers(): void {
|
||||
try {
|
||||
assertReady()
|
||||
const id = jobId || mainAppState.activeJobId
|
||||
stopJobPoll()
|
||||
stopJobPoll(true)
|
||||
let code = CS_OK
|
||||
if (isCancelApiAvailable()) {
|
||||
code = dllAdminJobCancel(id)
|
||||
@@ -166,7 +174,7 @@ export function registerIpcHandlers(): void {
|
||||
})
|
||||
|
||||
ipcMain.handle('poll:job-stop', () => {
|
||||
stopJobPoll()
|
||||
stopJobPoll(true)
|
||||
return ok()
|
||||
})
|
||||
|
||||
@@ -182,13 +190,17 @@ export function registerIpcHandlers(): void {
|
||||
})
|
||||
|
||||
ipcMain.handle('dialog:open-directory', async () => {
|
||||
const r = await dialog.showOpenDialog({ properties: ['openDirectory', 'multiSelections'] })
|
||||
const win = getPollMainWindow()
|
||||
const r = await dialog.showOpenDialog(win ?? undefined, {
|
||||
properties: ['openDirectory', 'multiSelections']
|
||||
})
|
||||
if (r.canceled || !r.filePaths.length) return ok({ paths: [] as string[] })
|
||||
return ok({ paths: r.filePaths })
|
||||
})
|
||||
|
||||
ipcMain.handle('dialog:open-file', async (_e, filters?: { name: string; extensions: string[] }[]) => {
|
||||
const r = await dialog.showOpenDialog({
|
||||
const win = getPollMainWindow()
|
||||
const r = await dialog.showOpenDialog(win ?? undefined, {
|
||||
properties: ['openFile'],
|
||||
filters: filters ?? [{ name: 'Soon', extensions: ['soon'] }]
|
||||
})
|
||||
@@ -197,23 +209,67 @@ export function registerIpcHandlers(): void {
|
||||
})
|
||||
|
||||
ipcMain.handle('fs:path-exists', (_e, paths: string[]) => {
|
||||
const missing = paths.filter((p) => {
|
||||
const clean = p.replace(/\\\*\\.\\*$/i, '').replace(/\/\*\.\*$/i, '')
|
||||
return !fs.existsSync(clean)
|
||||
})
|
||||
const missing = paths
|
||||
.map((raw) => ({ raw, dir: cleanPathPattern(raw) }))
|
||||
.filter(({ dir }) => !dir || !fs.existsSync(dir))
|
||||
.map(({ dir, raw }) => dir || raw)
|
||||
return ok({ missing })
|
||||
})
|
||||
|
||||
ipcMain.handle('config:get', () =>
|
||||
ok({
|
||||
sharedDir: configStore.get('sharedDir'),
|
||||
templateDir: configStore.get('templateDir'),
|
||||
skipDllInit: configStore.get('skipDllInit', !app.isPackaged)
|
||||
ipcMain.handle('fs:dir-size', (_e, paths: string[]) => {
|
||||
const items = paths.map((raw) => {
|
||||
const dir = cleanPathPattern(raw)
|
||||
if (!fs.existsSync(dir)) return { path: raw, bytes: 0, missing: true as const }
|
||||
try {
|
||||
const st = fs.statSync(dir)
|
||||
if (!st.isDirectory()) return { path: raw, bytes: st.size }
|
||||
return { path: raw, bytes: getDirectorySizeBytes(dir) }
|
||||
} catch {
|
||||
return { path: raw, bytes: 0, missing: true as const }
|
||||
}
|
||||
})
|
||||
)
|
||||
return ok({ items })
|
||||
})
|
||||
|
||||
ipcMain.handle('config:set', (_e, patch: Record<string, string>) => {
|
||||
Object.entries(patch).forEach(([k, v]) => configStore.set(k, v))
|
||||
ipcMain.handle('fs:parse-soon', (_e, filePath: string) => {
|
||||
try {
|
||||
const soonPath = String(filePath || '').trim()
|
||||
if (!soonPath) return fail(CS_FAIL, '模板路径为空')
|
||||
if (!fs.existsSync(soonPath)) return fail(CS_FAIL, '模板文件不存在')
|
||||
const raw = JSON.parse(fs.readFileSync(soonPath, 'utf8')) as Record<string, unknown>
|
||||
return ok(parseSoonTemplate(soonPath, raw))
|
||||
} catch (err) {
|
||||
log.error('fs:parse-soon', err)
|
||||
return fail(CS_FAIL, err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('config:get', () => {
|
||||
const payload: {
|
||||
sharedDir: string
|
||||
templateDir: string
|
||||
skipDllInit?: boolean
|
||||
} = {
|
||||
sharedDir: configStore.get('sharedDir'),
|
||||
templateDir: configStore.get('templateDir')
|
||||
}
|
||||
if (!app.isPackaged) {
|
||||
payload.skipDllInit = configStore.get('skipDllInit', false)
|
||||
}
|
||||
return ok(payload)
|
||||
})
|
||||
|
||||
ipcMain.handle('config:set', (_e, patch: Record<string, unknown>) => {
|
||||
Object.entries(patch).forEach(([k, v]) => {
|
||||
if (k === 'skipDllInit') {
|
||||
if (app.isPackaged) return
|
||||
const b =
|
||||
v === true || v === 'true' || v === 1 || v === '1' || String(v).toLowerCase() === 'true'
|
||||
configStore.set(k, b)
|
||||
return
|
||||
}
|
||||
configStore.set(k, v as string)
|
||||
})
|
||||
return ok(configStore.store)
|
||||
})
|
||||
|
||||
@@ -228,17 +284,19 @@ export function registerIpcHandlers(): void {
|
||||
}
|
||||
|
||||
export async function handleBeforeQuit(): Promise<void> {
|
||||
stopAllPolls()
|
||||
if (
|
||||
const shouldCancel =
|
||||
mainAppState.mode === 'distributing' &&
|
||||
mainAppState.activeJobId &&
|
||||
!!mainAppState.activeJobId &&
|
||||
isCancelApiAvailable()
|
||||
) {
|
||||
const cancelJobId = mainAppState.activeJobId
|
||||
stopAllPolls()
|
||||
if (shouldCancel && cancelJobId) {
|
||||
try {
|
||||
dllAdminJobCancel(mainAppState.activeJobId)
|
||||
dllAdminJobCancel(cancelJobId)
|
||||
} catch (e) {
|
||||
log.warn('before-quit cancel', e)
|
||||
}
|
||||
}
|
||||
mainAppState.mode = 'ready'
|
||||
mainAppState.activeJobId = ''
|
||||
}
|
||||
|
||||
@@ -5,17 +5,18 @@ import path from 'path'
|
||||
interface AppConfig {
|
||||
sharedDir: string
|
||||
templateDir: string
|
||||
/** 开发默认 true:不调用 SAPI_Init,避免无打印机时 DLL 刷错 */
|
||||
/** G2 门禁 false:启动即 SAPI_Init;仅调试可改 true */
|
||||
skipDllInit: boolean
|
||||
}
|
||||
|
||||
const defaultShared = path.join(app.getPath('userData'), 'Cardsoon', 'tasks')
|
||||
const defaultShared = path.join('C:', 'PrintTasks')
|
||||
|
||||
export const configStore = new Store<AppConfig>({
|
||||
name: 'cardsoon-config',
|
||||
defaults: {
|
||||
sharedDir: defaultShared,
|
||||
templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'),
|
||||
skipDllInit: !app.isPackaged
|
||||
// 正式版始终 Init;仅开发时可通过 --skip-dll-init 临时跳过
|
||||
skipDllInit: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -83,6 +83,10 @@ export function setupNativeWorkingDir(): void {
|
||||
}
|
||||
ensureBundledConfig(nativeDir)
|
||||
deployRuntimeConfigs(nativeDir)
|
||||
process.chdir(nativeDir)
|
||||
log.debug(`Native working directory: ${nativeDir}`)
|
||||
const execDir = getProcessExecDir()
|
||||
const pathHead = [nativeDir, execDir].join(path.delimiter)
|
||||
if (!process.env.PATH?.toLowerCase().includes(nativeDir.toLowerCase())) {
|
||||
process.env.PATH = `${pathHead}${path.delimiter}${process.env.PATH || ''}`
|
||||
}
|
||||
log.debug(`Native DLL search path: ${nativeDir}; cwd kept at ${process.cwd()}`)
|
||||
}
|
||||
|
||||
@@ -13,17 +13,25 @@ export function setPollMainWindow(win: BrowserWindow): void {
|
||||
mainWindow = win
|
||||
}
|
||||
|
||||
export function getPollMainWindow(): BrowserWindow | null {
|
||||
return mainWindow && !mainWindow.isDestroyed() ? mainWindow : null
|
||||
}
|
||||
|
||||
function send(channel: string, payload: unknown): void {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(channel, payload)
|
||||
}
|
||||
}
|
||||
|
||||
export function stopJobPoll(): void {
|
||||
export function stopJobPoll(resetMode = false): void {
|
||||
if (jobTimer) {
|
||||
clearInterval(jobTimer)
|
||||
jobTimer = null
|
||||
}
|
||||
if (resetMode && mainAppState.mode === 'distributing') {
|
||||
mainAppState.mode = 'ready'
|
||||
mainAppState.activeJobId = ''
|
||||
}
|
||||
}
|
||||
|
||||
export function stopUsbPoll(): void {
|
||||
@@ -34,12 +42,12 @@ export function stopUsbPoll(): void {
|
||||
}
|
||||
|
||||
export function stopAllPolls(): void {
|
||||
stopJobPoll()
|
||||
stopJobPoll(true)
|
||||
stopUsbPoll()
|
||||
}
|
||||
|
||||
export function startJobPoll(id: string): void {
|
||||
stopJobPoll()
|
||||
stopJobPoll(false)
|
||||
jobId = id
|
||||
jobTimer = setInterval(() => {
|
||||
try {
|
||||
@@ -60,16 +68,15 @@ export function startJobPoll(id: string): void {
|
||||
})
|
||||
if (r.queryErrorCode !== 0) {
|
||||
log.warn('GetJobStateById query failed', r.queryErrorCode)
|
||||
stopJobPoll()
|
||||
stopJobPoll(true)
|
||||
return
|
||||
}
|
||||
if (failed || cancelled) {
|
||||
stopJobPoll()
|
||||
if (cancelled) mainAppState.mode = 'ready'
|
||||
stopJobPoll(true)
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('job poll error', e)
|
||||
stopJobPoll()
|
||||
stopJobPoll(true)
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function dllInit(params: InitParams): number {
|
||||
params.stopOnFailure ?? false,
|
||||
params.cleanTaskFile ?? true,
|
||||
params.autoRetryTimes ?? 0,
|
||||
params.rejectConfig ?? true,
|
||||
params.rejectConfig ?? false,
|
||||
params.logLevel ?? LOG_FATAL_FLAG,
|
||||
params.outBack ?? false
|
||||
) as number
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { cleanPathPattern } from '@shared/path-pattern'
|
||||
|
||||
export { cleanPathPattern }
|
||||
|
||||
export function getDirectorySizeBytes(dirPath: string): number {
|
||||
let total = 0
|
||||
const stack = [dirPath]
|
||||
while (stack.length) {
|
||||
const current = stack.pop()!
|
||||
let entries: fs.Dirent[]
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const full = path.join(current, ent.name)
|
||||
if (ent.isDirectory()) {
|
||||
stack.push(full)
|
||||
} else if (ent.isFile()) {
|
||||
try {
|
||||
total += fs.statSync(full).size
|
||||
} catch {
|
||||
/* skip unreadable file */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import path from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
|
||||
export interface TemplateFieldRow {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ParsedSoonTemplate {
|
||||
frontImageUrl: string
|
||||
backImageUrl: string
|
||||
fields: TemplateFieldRow[]
|
||||
}
|
||||
|
||||
function pickArray(obj: Record<string, unknown>, key: string): Record<string, unknown>[] {
|
||||
const entry = Object.entries(obj).find(([k]) => k.toLowerCase() === key.toLowerCase())
|
||||
if (!Array.isArray(entry?.[1])) return []
|
||||
return (entry[1] as unknown[]).filter((x) => x && typeof x === 'object') as Record<string, unknown>[]
|
||||
}
|
||||
|
||||
function pickStr(item: Record<string, unknown>, keys: string[]): string {
|
||||
for (const k of keys) {
|
||||
const hit = Object.entries(item).find(([name]) => name.toLowerCase() === k.toLowerCase())
|
||||
if (hit && hit[1] != null && String(hit[1]).trim()) return String(hit[1]).trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function sideOf(item: Record<string, unknown>): 'front' | 'back' | '' {
|
||||
const raw = pickStr(item, ['side', 'face', 'surface', 'cardface']).toLowerCase()
|
||||
if (!raw) return ''
|
||||
if (['front', '0', 'f', '正面', '正'].includes(raw)) return 'front'
|
||||
if (['back', '1', 'b', '背面', '背'].includes(raw)) return 'back'
|
||||
return ''
|
||||
}
|
||||
|
||||
function sideLabel(side: 'front' | 'back'): string {
|
||||
return side === 'front' ? '正面' : '背面'
|
||||
}
|
||||
|
||||
function resolveAssetPath(soonPath: string, ref: string): string {
|
||||
if (!ref) return ''
|
||||
const clean = ref.replace(/^file:\/\//i, '')
|
||||
const abs = path.isAbsolute(clean) ? clean : path.join(path.dirname(soonPath), clean)
|
||||
return pathToFileURL(abs).href
|
||||
}
|
||||
|
||||
function toFieldLabel(name: string, side: 'front' | 'back'): string {
|
||||
return `${name} [${sideLabel(side)}]`
|
||||
}
|
||||
|
||||
export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
|
||||
const imgs = pickArray(raw, 'Img')
|
||||
const texts = pickArray(raw, 'Text')
|
||||
|
||||
let frontImageUrl = ''
|
||||
let backImageUrl = ''
|
||||
const fields: TemplateFieldRow[] = []
|
||||
|
||||
imgs.forEach((item, idx) => {
|
||||
const fileRef = pickStr(item, ['file', 'path', 'img', 'image', 'src', 'filename'])
|
||||
if (!fileRef) return
|
||||
let side = sideOf(item)
|
||||
if (!side) side = idx === 0 ? 'front' : idx === 1 ? 'back' : 'front'
|
||||
const url = resolveAssetPath(soonPath, fileRef)
|
||||
if (side === 'front') {
|
||||
if (!frontImageUrl) frontImageUrl = url
|
||||
const name = pickStr(item, ['name', 'field', 'key']) || 'IMAGE'
|
||||
fields.push({ label: toFieldLabel(name, 'front'), value: fileRef })
|
||||
} else if (!backImageUrl) {
|
||||
backImageUrl = url
|
||||
}
|
||||
})
|
||||
|
||||
texts.forEach((item) => {
|
||||
const name = pickStr(item, ['name', 'field', 'key', 'id'])
|
||||
if (!name) return
|
||||
const value = pickStr(item, ['value', 'text', 'default', 'content', 'data'])
|
||||
let side = sideOf(item)
|
||||
if (!side) side = /image|img|front/i.test(name) ? 'front' : 'back'
|
||||
fields.push({ label: toFieldLabel(name, side), value })
|
||||
})
|
||||
|
||||
return { frontImageUrl, backImageUrl, fields }
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
const SUPPRESS_PATTERNS = [
|
||||
'Card Printer not detected',
|
||||
'PrinterAdaptor.cpp',
|
||||
@@ -14,8 +12,6 @@ function shouldSuppress(chunk: string | Uint8Array): boolean {
|
||||
}
|
||||
|
||||
export function suppressKnownDllStderr(): void {
|
||||
if (app.isPackaged) return
|
||||
|
||||
const stderr = process.stderr
|
||||
const original = stderr.write.bind(stderr)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user