| 1 | import { ipcMain } from 'electron' |
| 2 | import { |
| 3 | MASTER_BACKGROUND_MODES, |
| 4 | MASTER_BACKGROUND_STYLES, |
| 5 | MASTER_FONT_PRESETS, |
| 6 | MASTER_GRADIENT_TYPES, |
| 7 | MAX_MASTER_BODY_FONT_SIZE, |
| 8 | MAX_MASTER_TITLE_FONT_SIZE, |
| 9 | MAX_MASTER_GRADIENT_STOPS, |
| 10 | MIN_MASTER_BODY_FONT_SIZE, |
| 11 | MIN_MASTER_TITLE_FONT_SIZE, |
| 12 | MIN_MASTER_GRADIENT_STOPS, |
| 13 | normalizeMasterConfig, |
| 14 | type SessionMasterConfig |
| 15 | } from '@shared/master' |
| 16 | import { isValidSessionLayoutLibrary, type SessionLayoutLibrary } from '@shared/layout-master' |
| 17 | import type { IpcContext } from '../ipc/context' |
| 18 | import { |
| 19 | getSessionMasterStatus, |
| 20 | getSessionLayoutLibraryStatus, |
| 21 | saveSessionLayoutLibrary, |
| 22 | saveSessionMaster, |
| 23 | setSessionMasterPageOverride |
| 24 | } from './master-mutation-service' |
| 25 | |
| 26 | const getPayload = (value: unknown): Record<string, unknown> => |
| 27 | value && typeof value === 'object' && !Array.isArray(value) |
| 28 | ? (value as Record<string, unknown>) |
| 29 | : {} |
| 30 | |
| 31 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 32 | Boolean(value) && typeof value === 'object' && !Array.isArray(value) |
| 33 | |
| 34 | const isValidGradientStop = (value: unknown): boolean => |
| 35 | isRecord(value) && |
| 36 | typeof value.color === 'string' && |
| 37 | /^#[0-9a-fA-F]{6}$/.test(value.color) && |
| 38 | typeof value.position === 'number' && |
| 39 | Number.isFinite(value.position) && |
| 40 | value.position >= 0 && |
| 41 | value.position <= 100 |
| 42 | |
| 43 | const isValidGradient = (value: unknown): boolean => { |
| 44 | if (!isRecord(value)) return false |
| 45 | if (!MASTER_GRADIENT_TYPES.includes(value.type as (typeof MASTER_GRADIENT_TYPES)[number])) |
| 46 | return false |
| 47 | if ( |
| 48 | typeof value.angle !== 'number' || |
| 49 | !Number.isFinite(value.angle) || |
| 50 | value.angle < 0 || |
| 51 | value.angle > 359 |
| 52 | ) { |
| 53 | return false |
| 54 | } |
| 55 | if (!Array.isArray(value.stops)) return false |
| 56 | return ( |
| 57 | value.stops.length >= MIN_MASTER_GRADIENT_STOPS && |
| 58 | value.stops.length <= MAX_MASTER_GRADIENT_STOPS && |
| 59 | value.stops.every(isValidGradientStop) |
| 60 | ) |
| 61 | } |
| 62 | |
| 63 | const requireSessionId = (value: unknown): string => { |
| 64 | const sessionId = typeof value === 'string' ? value.trim() : '' |
| 65 | if (!sessionId) throw new Error('缺少 sessionId') |
| 66 | return sessionId |
| 67 | } |
| 68 | |
| 69 | const isValidBackgroundImage = (value: unknown): boolean => { |
| 70 | if (value === undefined || value === null) return true |
| 71 | if (typeof value !== 'string') return false |
| 72 | const imagePath = value.trim() |
| 73 | const fileName = imagePath.slice('./images/'.length) |
| 74 | return ( |
| 75 | imagePath.startsWith('./images/') && |
| 76 | fileName.length > 0 && |
| 77 | fileName !== '.' && |
| 78 | fileName !== '..' && |
| 79 | !fileName.includes('/') && |
| 80 | !fileName.includes('\\') && |
| 81 | !fileName.includes('\0') |
| 82 | ) |
| 83 | } |
| 84 | |
| 85 | const isValidMasterElementText = (value: unknown, maxLength: number): boolean => |
| 86 | value === undefined || |
| 87 | (typeof value === 'string' && value.replace(/\s+/g, ' ').trim().length <= maxLength) |
| 88 | |
| 89 | const isValidMasterElementPosition = (value: unknown): boolean => |
| 90 | value === undefined || |
| 91 | (isRecord(value) && |
| 92 | typeof value.x === 'number' && |
| 93 | Number.isFinite(value.x) && |
| 94 | value.x >= 0 && |
| 95 | value.x <= 100 && |
| 96 | typeof value.y === 'number' && |
| 97 | Number.isFinite(value.y) && |
| 98 | value.y >= 0 && |
| 99 | value.y <= 100) |
| 100 | |
| 101 | const isValidMasterElementSize = (value: unknown): boolean => |
| 102 | value === undefined || |
| 103 | (isRecord(value) && |
| 104 | typeof value.width === 'number' && |
| 105 | Number.isFinite(value.width) && |
| 106 | value.width >= 1 && |
| 107 | value.width <= 100 && |
| 108 | typeof value.height === 'number' && |
| 109 | Number.isFinite(value.height) && |
| 110 | value.height >= 1 && |
| 111 | value.height <= 100) |
| 112 | |
| 113 | const isValidMasterElementVisibility = (value: unknown): boolean => |
| 114 | value === undefined || typeof value === 'boolean' |
| 115 | |
| 116 | const isValidMasterElementFontSize = (value: unknown): boolean => |
| 117 | value === undefined || |
| 118 | (typeof value === 'number' && |
| 119 | Number.isFinite(value) && |
| 120 | Number.isInteger(value) && |
| 121 | value >= 8 && |
| 122 | value <= 160) |
| 123 | |
| 124 | const isValidMasterElementRotation = (value: unknown): boolean => |
| 125 | value === undefined || |
| 126 | (typeof value === 'number' && |
| 127 | Number.isFinite(value) && |
| 128 | Number.isInteger(value) && |
| 129 | value >= -180 && |
| 130 | value <= 180) |
| 131 | |
| 132 | const isValidMasterElementColor = (value: unknown): boolean => |
| 133 | value === undefined || (typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value)) |
| 134 | |
| 135 | const isValidMasterElements = (value: unknown): boolean => { |
| 136 | if (value === undefined) return true |
| 137 | if (!isRecord(value)) return false |
| 138 | return ( |
| 139 | isValidBackgroundImage(value.logoImage) && |
| 140 | isValidMasterElementText(value.footerText, 180) && |
| 141 | isValidMasterElementText(value.watermarkText, 80) && |
| 142 | isValidMasterElementVisibility(value.showLogo) && |
| 143 | isValidMasterElementVisibility(value.showFooter) && |
| 144 | isValidMasterElementVisibility(value.showPageNumber) && |
| 145 | isValidMasterElementVisibility(value.showWatermark) && |
| 146 | isValidMasterElementFontSize(value.footerFontSize) && |
| 147 | isValidMasterElementFontSize(value.pageNumberFontSize) && |
| 148 | isValidMasterElementColor(value.footerColor) && |
| 149 | isValidMasterElementColor(value.pageNumberColor) && |
| 150 | isValidMasterElementRotation(value.watermarkRotation) && |
| 151 | isValidMasterElementVisibility(value.watermarkSizeAuto) && |
| 152 | isValidMasterElementPosition(value.logoPosition) && |
| 153 | isValidMasterElementPosition(value.footerPosition) && |
| 154 | isValidMasterElementPosition(value.pageNumberPosition) && |
| 155 | isValidMasterElementPosition(value.watermarkPosition) && |
| 156 | isValidMasterElementSize(value.logoSize) && |
| 157 | isValidMasterElementSize(value.footerSize) && |
| 158 | isValidMasterElementSize(value.pageNumberSize) && |
| 159 | isValidMasterElementSize(value.watermarkSize) |
| 160 | ) |
| 161 | } |
| 162 | |
| 163 | const requireMasterConfig = (value: unknown): SessionMasterConfig => { |
| 164 | if (!value || typeof value !== 'object' || Array.isArray(value)) { |
| 165 | throw new Error('母版配置无效') |
| 166 | } |
| 167 | const input = value as Record<string, unknown> |
| 168 | const isValidFontFamily = (font: unknown): boolean => |
| 169 | font === undefined || |
| 170 | font === null || |
| 171 | (typeof font === 'string' && font.trim().length > 0 && font.trim().length <= 120) |
| 172 | const isValidFontSize = (fontSize: unknown, min: number, max: number): boolean => |
| 173 | fontSize === undefined || |
| 174 | fontSize === null || |
| 175 | (typeof fontSize === 'number' && |
| 176 | Number.isInteger(fontSize) && |
| 177 | Number.isFinite(fontSize) && |
| 178 | fontSize >= min && |
| 179 | fontSize <= max) |
| 180 | if ( |
| 181 | typeof input.backgroundColor !== 'string' || |
| 182 | !/^#[0-9a-fA-F]{6}$/.test(input.backgroundColor.trim()) || |
| 183 | typeof input.backgroundMode !== 'string' || |
| 184 | !MASTER_BACKGROUND_MODES.includes( |
| 185 | input.backgroundMode as SessionMasterConfig['backgroundMode'] |
| 186 | ) || |
| 187 | typeof input.backgroundStyle !== 'string' || |
| 188 | !MASTER_BACKGROUND_STYLES.includes( |
| 189 | input.backgroundStyle as SessionMasterConfig['backgroundStyle'] |
| 190 | ) || |
| 191 | !isValidGradient(input.backgroundGradient) || |
| 192 | !isValidBackgroundImage(input.backgroundImage) || |
| 193 | (input.backgroundStyle === 'image' && typeof input.backgroundImage !== 'string') || |
| 194 | typeof input.titleFontPreset !== 'string' || |
| 195 | !MASTER_FONT_PRESETS.includes( |
| 196 | input.titleFontPreset as SessionMasterConfig['titleFontPreset'] |
| 197 | ) || |
| 198 | typeof input.bodyFontPreset !== 'string' || |
| 199 | !MASTER_FONT_PRESETS.includes(input.bodyFontPreset as SessionMasterConfig['bodyFontPreset']) || |
| 200 | !isValidFontFamily(input.titleFontFamily) || |
| 201 | !isValidFontFamily(input.bodyFontFamily) || |
| 202 | !isValidFontSize(input.titleFontSize, MIN_MASTER_TITLE_FONT_SIZE, MAX_MASTER_TITLE_FONT_SIZE) || |
| 203 | !isValidFontSize(input.bodyFontSize, MIN_MASTER_BODY_FONT_SIZE, MAX_MASTER_BODY_FONT_SIZE) || |
| 204 | !isValidMasterElements(input.elements) |
| 205 | ) { |
| 206 | throw new Error('母版配置无效') |
| 207 | } |
| 208 | return normalizeMasterConfig(input) |
| 209 | } |
| 210 | |
| 211 | const requirePageOverride = (value: unknown): { pageId: string; disabled: boolean } => { |
| 212 | const input = getPayload(value) |
| 213 | const pageId = typeof input.pageId === 'string' ? input.pageId.trim() : '' |
| 214 | if (!pageId || typeof input.disabled !== 'boolean') throw new Error('页面母版设置无效') |
| 215 | return { pageId, disabled: input.disabled } |
| 216 | } |
| 217 | |
| 218 | const requireSessionLayoutLibrary = (value: unknown): SessionLayoutLibrary => { |
| 219 | if (!isValidSessionLayoutLibrary(value)) throw new Error('版式母版配置无效') |
| 220 | return value |
| 221 | } |
| 222 | |
| 223 | export function registerMasterHandlers(ctx: IpcContext): void { |
| 224 | ipcMain.handle('session:getMaster', async (_event, payload: unknown) => { |
| 225 | const { sessionId } = getPayload(payload) |
| 226 | return getSessionMasterStatus(ctx, requireSessionId(sessionId)) |
| 227 | }) |
| 228 | |
| 229 | ipcMain.handle('session:saveMaster', async (_event, payload: unknown) => { |
| 230 | const { sessionId, config } = getPayload(payload) |
| 231 | return saveSessionMaster(ctx, requireSessionId(sessionId), requireMasterConfig(config)) |
| 232 | }) |
| 233 | |
| 234 | ipcMain.handle('session:setMasterPageOverride', async (_event, payload: unknown) => { |
| 235 | const { sessionId } = getPayload(payload) |
| 236 | const { pageId, disabled } = requirePageOverride(payload) |
| 237 | return setSessionMasterPageOverride(ctx, requireSessionId(sessionId), pageId, disabled) |
| 238 | }) |
| 239 | |
| 240 | ipcMain.handle('session:getLayoutLibrary', async (_event, payload: unknown) => { |
| 241 | const { sessionId } = getPayload(payload) |
| 242 | return getSessionLayoutLibraryStatus(ctx, requireSessionId(sessionId)) |
| 243 | }) |
| 244 | |
| 245 | ipcMain.handle('session:saveLayoutLibrary', async (_event, payload: unknown) => { |
| 246 | const { sessionId, library } = getPayload(payload) |
| 247 | return saveSessionLayoutLibrary( |
| 248 | ctx, |
| 249 | requireSessionId(sessionId), |
| 250 | requireSessionLayoutLibrary(library) |
| 251 | ) |
| 252 | }) |
| 253 | } |
| 254 |