ソースを参照

feat: 添加pipe连接、认证、日志打印功能

jiaxing.liao 3 週間 前
コミット
af736a7893

+ 1 - 0
.gitignore

@@ -1,6 +1,7 @@
 node_modules
 dist
 out
+pipe
 .DS_Store
 .eslintcache
 *.log*

+ 8 - 0
electron-builder.yml

@@ -11,6 +11,14 @@ files:
   - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
 asarUnpack:
   - resources/**
+
+# Pipe 服务端等资源:整目录复制到安装产物 resources/pipe(与 app.asar 同级,可执行文件不被 asar 打包)
+extraResources:
+  - from: resources/pipe
+    to: pipe
+    filter:
+      - '**/*'
+
 win:
   executableName: sunmicro-designer
 nsis:

+ 586 - 0
src/main/client.ts

@@ -0,0 +1,586 @@
+import type { BrowserWindow } from 'electron'
+import { connect, type Socket } from 'node:net'
+
+const DEFAULT_MASK = 0x9e3779b9
+const MAX_FRAME_SIZE = 16 * 1024 * 1024
+const UINT32_MASK = 0xffffffffn
+const DEFAULT_MASK_BIGINT = 0x9e3779b9n
+
+export const PIPE_PATH = '\\\\.\\pipe\\www.sv-elec.com'
+export const PIPE_EVENT_CHANNEL = 'pipe-event'
+
+type PipeLogLevel = 'info' | 'success' | 'warning' | 'error'
+type PipeLogCategory = 'connection' | 'auth' | 'message'
+type PipeLogDirection = 'system' | 'recv' | 'send'
+
+type PipeEvent = {
+  timestamp: string
+  level: PipeLogLevel
+  category: PipeLogCategory
+  direction: PipeLogDirection
+  message: string
+  detail?: string
+  msgId?: number
+  msgName?: string
+  payloadLength?: number
+  payloadHex?: string
+  payloadText?: string
+  connected: boolean
+  authenticated: boolean
+}
+
+type PipeEventPayload = Omit<PipeEvent, 'timestamp' | 'connected' | 'authenticated'>
+
+const MESSAGE_NAMES: Record<number, string> = {
+  0x0001: 'AUTH_CHALLENGE',
+  0x0002: 'AUTH_RESPONSE',
+  0x0003: 'AUTH_SUCCESS',
+  0x0004: 'AUTH_FAILED',
+  0x0005: 'AUTH_TIMEOUT',
+  0x0006: 'HEARTBEAT',
+  0x1001: 'CONNECT',
+  0x1002: 'DISCONNECT',
+  0x1003: 'STATUS_REQUEST',
+  0x1004: 'STATUS_RESPONSE',
+  0x2001: 'TEXT_DATA',
+  0x2002: 'BINARY_DATA',
+  0x2003: 'FILE_DATA',
+  0x3001: 'CUSTOM_1',
+  0x3002: 'CUSTOM_2',
+  0x3003: 'CUSTOM_3'
+}
+
+export class PipeClient {
+  private socket?: Socket
+  private buffer = Buffer.alloc(0)
+  private authenticated = false
+  private connecting = false
+  private closingReason: string | null = null
+  private disposed = false
+
+  constructor(private readonly window: BrowserWindow) {}
+
+  connect(): void {
+    if (this.disposed) return
+
+    if (this.isConnected()) {
+      this.emit({
+        level: 'info',
+        category: 'connection',
+        direction: 'system',
+        message: 'Pipe is already connected'
+      })
+      return
+    }
+
+    if (this.connecting) {
+      this.emit({
+        level: 'info',
+        category: 'connection',
+        direction: 'system',
+        message: 'Pipe connection is in progress'
+      })
+      return
+    }
+
+    this.buffer = Buffer.alloc(0)
+    this.authenticated = false
+    this.connecting = true
+    this.closingReason = null
+
+    this.emit({
+      level: 'info',
+      category: 'connection',
+      direction: 'system',
+      message: 'Connecting to pipe server'
+    })
+
+    const socket = connect(PIPE_PATH)
+    this.socket = socket
+
+    socket.on('connect', () => {
+      this.connecting = false
+      this.emit({
+        level: 'success',
+        category: 'connection',
+        direction: 'system',
+        message: 'Pipe connected, waiting for auth handshake'
+      })
+    })
+
+    socket.on('data', (chunk) => {
+      this.handleChunk(chunk)
+    })
+
+    socket.on('error', (error) => {
+      this.connecting = false
+      this.emit({
+        level: 'error',
+        category: 'connection',
+        direction: 'system',
+        message: 'Pipe connection error',
+        detail: formatSafeConnectionError(error)
+      })
+    })
+
+    socket.on('close', (hadError) => {
+      const reason = this.closingReason || (hadError ? 'Pipe closed after error' : 'Pipe closed')
+      this.socket = undefined
+      this.buffer = Buffer.alloc(0)
+      this.connecting = false
+      this.authenticated = false
+      this.closingReason = null
+
+      if (!this.disposed) {
+        this.emit({
+          level: hadError ? 'warning' : 'info',
+          category: 'connection',
+          direction: 'system',
+          message: reason
+        })
+      }
+    })
+  }
+
+  disconnect(reason = 'Pipe disconnected by client'): void {
+    if (!this.socket) {
+      this.connecting = false
+      this.authenticated = false
+      this.buffer = Buffer.alloc(0)
+      this.emit({
+        level: 'info',
+        category: 'connection',
+        direction: 'system',
+        message: 'No active pipe connection'
+      })
+      return
+    }
+
+    this.closingReason = reason
+    this.socket.end()
+  }
+
+  destroy(): void {
+    this.disposed = true
+    this.connecting = false
+    this.authenticated = false
+    this.buffer = Buffer.alloc(0)
+    this.closingReason = null
+
+    if (this.socket) {
+      this.socket.removeAllListeners()
+      this.socket.destroy()
+      this.socket = undefined
+    }
+  }
+
+  private isConnected(): boolean {
+    return !!this.socket && !this.socket.destroyed
+  }
+
+  private handleChunk(chunk: Buffer): void {
+    this.buffer = Buffer.concat([this.buffer, chunk])
+
+    while (this.buffer.length >= 6) {
+      const msgId = this.buffer.readUInt16BE(0)
+      const payloadLength = this.buffer.readUInt32BE(2)
+
+      if (payloadLength > MAX_FRAME_SIZE) {
+        this.emit({
+          level: 'error',
+          category: 'message',
+          direction: 'recv',
+          message: 'Frame length is too large, disconnecting',
+          detail: `msgId=0x${msgId.toString(16).toUpperCase().padStart(4, '0')}, length=${payloadLength}`
+        })
+        this.disconnect('Frame length exceeded limit')
+        return
+      }
+
+      const frameLength = 6 + payloadLength
+      if (this.buffer.length < frameLength) {
+        return
+      }
+
+      const payload = Buffer.from(this.buffer.subarray(6, frameLength))
+      this.buffer = this.buffer.subarray(frameLength)
+      this.handleFrame(msgId, payload)
+    }
+  }
+
+  private handleFrame(msgId: number, payload: Buffer): void {
+    if (!this.authenticated && ![0x0001, 0x0003, 0x0004, 0x0005, 0x0006].includes(msgId)) {
+      this.emit({
+        level: 'warning',
+        category: 'auth',
+        direction: 'recv',
+        message: `Received ${getMessageName(msgId)} before auth completed`,
+        msgId,
+        msgName: getMessageName(msgId),
+        payloadLength: payload.length,
+        payloadHex: toHexPreview(payload)
+      })
+      this.disconnect('Unexpected frame received before auth')
+      return
+    }
+
+    switch (msgId) {
+      case 0x0001:
+        this.handleAuthChallenge(payload)
+        return
+      case 0x0003:
+        this.authenticated = true
+        this.emit({
+          level: 'success',
+          category: 'auth',
+          direction: 'recv',
+          message: 'Auth success',
+          msgId,
+          msgName: getMessageName(msgId),
+          payloadLength: payload.length
+        })
+        return
+      case 0x0004:
+        this.emit({
+          level: 'error',
+          category: 'auth',
+          direction: 'recv',
+          message: 'Auth failed',
+          msgId,
+          msgName: getMessageName(msgId),
+          payloadLength: payload.length
+        })
+        this.disconnect('Auth failed')
+        return
+      case 0x0005:
+        this.emit({
+          level: 'warning',
+          category: 'auth',
+          direction: 'recv',
+          message: 'Auth timeout',
+          msgId,
+          msgName: getMessageName(msgId),
+          payloadLength: payload.length
+        })
+        this.disconnect('Auth timeout')
+        return
+      default:
+        this.emit(this.describeMessage(msgId, payload))
+    }
+  }
+
+  private handleAuthChallenge(payload: Buffer): void {
+    this.emit({
+      level: 'info',
+      category: 'auth',
+      direction: 'recv',
+      message: 'Received auth challenge',
+      detail:
+        payload.length === 16 ? payload.toString('hex').toUpperCase() : 'Invalid challenge length',
+      msgId: 0x0001,
+      msgName: getMessageName(0x0001),
+      payloadLength: payload.length,
+      payloadHex: toHexPreview(payload)
+    })
+
+    if (payload.length !== 16) {
+      this.emit({
+        level: 'error',
+        category: 'auth',
+        direction: 'system',
+        message: 'Challenge length must be 16 bytes'
+      })
+      this.disconnect('Invalid auth challenge length')
+      return
+    }
+
+    const response = simpleCryptoEncrypt(payload, payload)
+
+    this.emit({
+      level: 'info',
+      category: 'auth',
+      direction: 'send',
+      message: 'Sending auth response',
+      detail: response.toString('hex').toUpperCase(),
+      msgId: 0x0002,
+      msgName: getMessageName(0x0002),
+      payloadLength: response.length,
+      payloadHex: toHexPreview(response)
+    })
+
+    this.writeFrame(0x0002, response)
+  }
+
+  private writeFrame(msgId: number, payload = Buffer.alloc(0)): void {
+    if (!this.socket || this.socket.destroyed) {
+      this.emit({
+        level: 'warning',
+        category: 'connection',
+        direction: 'system',
+        message: 'Cannot write frame, pipe is not connected'
+      })
+      return
+    }
+
+    const frame = Buffer.allocUnsafe(6 + payload.length)
+    frame.writeUInt16BE(msgId, 0)
+    frame.writeUInt32BE(payload.length, 2)
+    payload.copy(frame, 6)
+
+    this.emit({
+      level: 'info',
+      category: msgId === 0x0002 ? 'auth' : 'message',
+      direction: 'send',
+      message: `Queue frame ${getMessageName(msgId)}`,
+      detail: frame.toString('hex').toUpperCase(),
+      msgId,
+      msgName: getMessageName(msgId),
+      payloadLength: payload.length,
+      payloadHex: toHexPreview(payload)
+    })
+
+    this.socket.write(frame, (error) => {
+      if (error) {
+        this.emit({
+          level: 'error',
+          category: msgId === 0x0002 ? 'auth' : 'message',
+          direction: 'send',
+          message: `Write failed for ${getMessageName(msgId)}`,
+          detail: formatSafeConnectionError(error),
+          msgId,
+          msgName: getMessageName(msgId),
+          payloadLength: payload.length,
+          payloadHex: toHexPreview(payload)
+        })
+        return
+      }
+
+      this.emit({
+        level: 'success',
+        category: msgId === 0x0002 ? 'auth' : 'message',
+        direction: 'send',
+        message: `Frame sent ${getMessageName(msgId)}`,
+        msgId,
+        msgName: getMessageName(msgId),
+        payloadLength: payload.length,
+        payloadHex: toHexPreview(payload)
+      })
+    })
+  }
+
+  private describeMessage(msgId: number, payload: Buffer): PipeEventPayload {
+    const msgName = getMessageName(msgId)
+    const hex = toHexPreview(payload)
+    const base: PipeEventPayload = {
+      level: 'info',
+      category: 'message',
+      direction: 'recv',
+      message: `Received ${msgName}`,
+      msgId,
+      msgName,
+      payloadLength: payload.length,
+      payloadHex: hex
+    }
+
+    switch (msgId) {
+      case 0x0006:
+        return {
+          ...base,
+          message: 'Received heartbeat'
+        }
+      case 0x1004: {
+        const text = tryDecodeUtf8(payload)
+        return {
+          ...base,
+          message: 'Received status response',
+          detail: text || hex,
+          payloadText: text
+        }
+      }
+      case 0x2001: {
+        const text = payload.toString('utf8')
+        return {
+          ...base,
+          message: 'Received text message',
+          detail: text,
+          payloadText: text
+        }
+      }
+      case 0x2002:
+        return {
+          ...base,
+          message: `Received binary message (${payload.length} bytes)`,
+          detail: hex
+        }
+      case 0x2003:
+        return describeFileMessage(base, payload)
+      default: {
+        const text = tryDecodeUtf8(payload)
+        return {
+          ...base,
+          message: `Received ${msgName} (0x${msgId.toString(16).toUpperCase().padStart(4, '0')})`,
+          detail: text || hex,
+          payloadText: text
+        }
+      }
+    }
+  }
+
+  private emit(event: PipeEventPayload): void {
+    if (this.window.isDestroyed()) return
+
+    this.window.webContents.send(PIPE_EVENT_CHANNEL, {
+      ...event,
+      timestamp: new Date().toISOString(),
+      connected: this.isConnected(),
+      authenticated: this.authenticated
+    } satisfies PipeEvent)
+  }
+}
+
+/** Avoid leaking named-pipe path / host into UI logs (Node error messages often include full path). */
+function redactPipeAddress(message: string): string {
+  return message
+    .replace(/\\\\\.\\pipe\\[^\s"'<>]+/gi, '[pipe]')
+    .replace(/www\.sv-elec\.com/gi, '[endpoint]')
+    .replace(/\s+/g, ' ')
+    .trim()
+}
+
+function formatSafeConnectionError(error: Error): string | undefined {
+  const code = (error as NodeJS.ErrnoException).code
+  const msg = redactPipeAddress(error.message || '')
+  if (code && msg) return `${code}: ${msg}`
+  if (msg) return msg
+  if (code) return code
+  return undefined
+}
+
+function getMessageName(msgId: number): string {
+  return MESSAGE_NAMES[msgId] || `UNKNOWN_0x${msgId.toString(16).toUpperCase().padStart(4, '0')}`
+}
+
+function toHexPreview(payload: Buffer, limit = 64): string | undefined {
+  if (!payload.length) return undefined
+  const preview = payload.subarray(0, limit).toString('hex').toUpperCase()
+  return payload.length > limit ? `${preview}...` : preview
+}
+
+function tryDecodeUtf8(payload: Buffer): string | undefined {
+  if (!payload.length) return undefined
+  const text = payload.toString('utf8')
+  return /^[\u0009\u000A\u000D\u0020-\u007E\u4E00-\u9FFF]*$/u.test(text) ? text : undefined
+}
+
+function describeFileMessage(base: PipeEventPayload, payload: Buffer): PipeEventPayload {
+  if (payload.length < 2) {
+    return {
+      ...base,
+      level: 'warning',
+      message: 'Received malformed file message',
+      detail: base.payloadHex
+    }
+  }
+
+  const fileNameLength = payload.readUInt16LE(0)
+  const fileNameEnd = 2 + fileNameLength
+
+  if (payload.length < fileNameEnd) {
+    return {
+      ...base,
+      level: 'warning',
+      message: 'Received invalid file header length',
+      detail: base.payloadHex
+    }
+  }
+
+  const fileName = payload.subarray(2, fileNameEnd).toString('utf8')
+  const fileDataLength = payload.length - fileNameEnd
+
+  return {
+    ...base,
+    message: `Received file message: ${fileName}`,
+    detail: `file=${fileName}\nsize=${fileDataLength} bytes`,
+    payloadText: fileName
+  }
+}
+
+function rotateLeft32(value: bigint, shift: number): bigint {
+  const shiftBigInt = BigInt(shift)
+  return ((value << shiftBigInt) | (value >> BigInt(32 - shift))) & UINT32_MASK
+}
+
+function rotateRight32(value: bigint, shift: number): bigint {
+  const shiftBigInt = BigInt(shift)
+  return ((value >> shiftBigInt) | (value << BigInt(32 - shift))) & UINT32_MASK
+}
+
+function seedToKey32(seed: number, mask = DEFAULT_MASK): number {
+  const maskValue = mask === DEFAULT_MASK ? DEFAULT_MASK_BIGINT : BigInt(mask >>> 0)
+  let value = BigInt(seed >>> 0)
+
+  if (value === 0n) {
+    return 0
+  }
+
+  // Matches Qt SimpleCrypto::seedToKey / seedToKey16 (8 rounds, bit28 => <<4^mask, else ~seed)
+  for (let index = 0; index < 8; index += 1) {
+    if ((value & 0x80000000n) !== 0n) {
+      value = ((value << 1n) ^ maskValue) & UINT32_MASK
+    } else if ((value & 0x40000000n) !== 0n) {
+      value = ((value << 2n) ^ maskValue) & UINT32_MASK
+    } else if ((value & 0x20000000n) !== 0n) {
+      value = ((value << 3n) ^ maskValue) & UINT32_MASK
+    } else if ((value & 0x10000000n) !== 0n) {
+      value = ((value << 4n) ^ maskValue) & UINT32_MASK
+    } else {
+      value = ~value & UINT32_MASK
+    }
+  }
+
+  const result =
+    ((value << 3n) ^ (maskValue >> 2n) ^ ((value >> 2n) ^ ((maskValue << 3n) & UINT32_MASK))) &
+    UINT32_MASK
+
+  return Number(result)
+}
+
+function simpleCryptoEncrypt(
+  data: Buffer,
+  seed16: Buffer,
+  mask = DEFAULT_MASK
+): Buffer<ArrayBuffer> {
+  const keys = [0, 1, 2, 3].map((index) => seedToKey32(seed16.readUInt32BE(index * 4), mask))
+  const result = Buffer.from(data)
+  const fullBlocks = Math.floor(result.length / 4)
+
+  for (let index = 0; index < fullBlocks; index += 1) {
+    let block = BigInt(result.readUInt32BE(index * 4))
+    const key = BigInt(keys[index % 4] >>> 0)
+
+    block = (block ^ key) & UINT32_MASK
+    block = (rotateLeft32(block, 3) ^ rotateRight32(key, 5)) & UINT32_MASK
+    block = (rotateRight32(block, 2) ^ rotateLeft32(key, 7)) & UINT32_MASK
+    block = (block ^ key) & UINT32_MASK
+
+    result.writeUInt32BE(Number(block), index * 4)
+  }
+
+  const tail = result.length % 4
+  if (tail > 0) {
+    const start = fullBlocks * 4
+    let block = 0n
+
+    for (let index = 0; index < tail; index += 1) {
+      block |= BigInt(result[start + index]) << BigInt(24 - index * 8)
+    }
+
+    block = (block ^ BigInt(keys[0] >>> 0)) & UINT32_MASK
+
+    for (let index = 0; index < tail; index += 1) {
+      result[start + index] = Number((block >> BigInt(24 - index * 8)) & 0xffn)
+    }
+  }
+
+  return result
+}

+ 35 - 53
src/main/index.ts

@@ -1,13 +1,14 @@
-import { app, shell, BrowserWindow, ipcMain, protocol, net as electronNet } from 'electron'
+import { app, BrowserWindow, ipcMain, protocol, shell, net as electronNet } from 'electron'
 import { join } from 'path'
-import { electronApp, optimizer, is } from '@electron-toolkit/utils'
+import { electronApp, is, optimizer } from '@electron-toolkit/utils'
 import icon from '../../resources/icon.png?asset'
+import { PipeClient } from './client'
 import { handleFile, unlockProjectFolder } from './files'
+import { ensurePipeServerRunning, stopSpawnedPipeServer } from './pipe-server'
 
-const net = require('net')
+let pipeClient: PipeClient | null = null
 
 function createWindow(): void {
-  // Create the browser window.
   const mainWindow = new BrowserWindow({
     width: 1920,
     height: 1080,
@@ -26,70 +27,50 @@ function createWindow(): void {
     mainWindow.show()
   })
 
+  mainWindow.on('closed', () => {
+    pipeClient?.destroy()
+    pipeClient = null
+  })
+
   mainWindow.webContents.setWindowOpenHandler((details) => {
     shell.openExternal(details.url)
     return { action: 'deny' }
   })
 
-  // HMR for renderer base on electron-vite cli.
-  // Load the remote URL for development or the local html file for production.
   if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
     mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
   } else {
     mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
   }
 
-  // 开发者工具
   if (is.dev) {
     mainWindow.webContents.openDevTools()
   }
 
-  // pipe命名管道通信
-  ipcMain.on('connect-pipe', () => {
-    try {
-      const pipe = net.connect('\\\\.\\Pipe\\MyPipeServer')
-
-      pipe.on('connect', () => {
-        mainWindow.webContents.send('pipe-connected', pipe)
-      })
-
-      pipe.on('data', (data) => {
-        mainWindow.webContents.send('pipe-message', data.toString())
-      })
-
-      pipe.on('error', (data) => {
-        mainWindow.webContents.send('pipe-error', data.toString())
-        console.log('pipe error:', data)
-      })
-
-      ipcMain.on('pipe-end', () => {
-        pipe.end()
-      })
-    } catch (error) {
-      console.error('Error connecting to pipe:', error)
-    }
-  })
+  pipeClient?.destroy()
+  pipeClient = new PipeClient(mainWindow)
 }
 
-// This method will be called when Electron has finished
-// initialization and is ready to create browser windows.
-// Some APIs can only be used after this event occurs.
-app.whenReady().then(() => {
-  // Set app user model id for windows
+app.whenReady().then(async () => {
   electronApp.setAppUserModelId('com.electron')
 
-  // Default open or close DevTools by F12 in development
-  // and ignore CommandOrControl + R in production.
-  // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
   app.on('browser-window-created', (_, window) => {
     optimizer.watchWindowShortcuts(window)
   })
 
-  // IPC test
   ipcMain.on('ping', () => console.log('pong'))
-  // 文件处理
+  ipcMain.on('connect-pipe', () => {
+    pipeClient?.connect()
+  })
+  ipcMain.on('disconnect-pipe', () => {
+    pipeClient?.disconnect()
+  })
+  ipcMain.on('pipe-end', () => {
+    pipeClient?.disconnect()
+  })
+
   handleFile()
-  // 注册文件协议
+
   protocol.handle('local', (request) => {
     const filePath = request.url.slice('local://'.length)
     return electronNet.fetch(`file://${filePath}`.toString())
@@ -97,26 +78,27 @@ app.whenReady().then(() => {
 
   createWindow()
 
-  app.on('activate', function () {
-    // On macOS it's common to re-create a window in the app when the
-    // dock icon is clicked and there are no other windows open.
+  try {
+    await ensurePipeServerRunning()
+  } catch (error) {
+    console.error('[pipe] ensurePipeServerRunning failed:', error)
+  }
+
+  pipeClient?.connect()
+
+  app.on('activate', () => {
     if (BrowserWindow.getAllWindows().length === 0) createWindow()
   })
 })
 
-// 退出前解锁项目文件夹,释放文件句柄
 app.on('before-quit', () => {
+  pipeClient?.destroy()
+  stopSpawnedPipeServer()
   unlockProjectFolder()
 })
 
-// Quit when all windows are closed, except on macOS. There, it's common
-// for applications and their menu bar to stay active until the user quits
-// explicitly with Cmd + Q.
 app.on('window-all-closed', () => {
   if (process.platform !== 'darwin') {
     app.quit()
   }
 })
-
-// In this file you can include the rest of your app's specific main process
-// code. You can also put them in separate files and require them here.

+ 11 - 0
src/main/pipe-path.ts

@@ -0,0 +1,11 @@
+import { app } from 'electron'
+import { join } from 'path'
+
+/** Bundled `resources/pipe` (extraResources) — use for spawning pipe.exe etc. */
+export function getPipeBundleDir(): string {
+  if (app.isPackaged) {
+    return join(process.resourcesPath, 'pipe')
+  }
+  // In dev/preview, run from project root so we can use the plain resources/pipe folder
+  return join(process.cwd(), 'resources/pipe')
+}

+ 114 - 0
src/main/pipe-server.ts

@@ -0,0 +1,114 @@
+import { spawn, type ChildProcess } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { connect } from 'node:net'
+import { getPipeBundleDir } from './pipe-path'
+import { PIPE_PATH } from './client'
+
+/** 本进程拉起的 pipe 服务端子进程(若由外部已启动同名管道,不会记录在此) */
+let spawnedPipeServer: ChildProcess | null = null
+
+function getPipeServerExecutablePath(): string {
+  const dir = getPipeBundleDir()
+  if (process.platform === 'win32') {
+    return join(dir, 'pipe.exe')
+  }
+  return join(dir, 'pipe')
+}
+
+function probeNamedPipe(timeoutMs: number): Promise<boolean> {
+  return new Promise((resolve) => {
+    let finished = false
+    const socket = connect(PIPE_PATH)
+    const timer = setTimeout(() => {
+      if (finished) return
+      finished = true
+      socket.destroy()
+      resolve(false)
+    }, timeoutMs)
+
+    const done = (ok: boolean) => {
+      if (finished) return
+      finished = true
+      clearTimeout(timer)
+      socket.destroy()
+      resolve(ok)
+    }
+
+    socket.once('connect', () => done(true))
+    socket.once('error', () => done(false))
+  })
+}
+
+async function waitUntilPipeAccepts(totalMs: number, intervalMs: number): Promise<void> {
+  const deadline = Date.now() + totalMs
+  while (Date.now() < deadline) {
+    if (await probeNamedPipe(500)) {
+      return
+    }
+    await new Promise((r) => setTimeout(r, intervalMs))
+  }
+  throw new Error(`Pipe server not ready after ${totalMs}ms`)
+}
+
+/**
+ * Windows:若命名管道尚不可用,则从 `resources/pipe` 启动 `pipe.exe`,阻塞直到可连。
+ * 若管道已存在(用户已手动启动服务端),则直接返回。
+ * 若未找到打包的 `pipe.exe`,仅打日志,不抛错(仍可连接外部服务端)。
+ */
+export async function ensurePipeServerRunning(): Promise<void> {
+  console.log('[pipe] ensurePipeServerRunning', process.platform)
+  if (process.platform !== 'win32') {
+    console.warn('[pipe] only supported on Windows')
+    return
+  }
+
+  if (await probeNamedPipe(300)) {
+    console.log('[pipe] pipe server already running')
+    return
+  }
+
+  const exe = getPipeServerExecutablePath()
+  if (!existsSync(exe)) {
+    console.warn('[pipe] bundled pipe server not found, skip auto-start')
+    return
+  }
+
+  const child = spawn(exe, [], {
+    cwd: getPipeBundleDir(),
+    detached: false,
+    stdio: 'ignore'
+    // 是否隐藏窗口
+    // windowsHide: true
+  })
+  spawnedPipeServer = child
+
+  child.on('error', (err) => {
+    console.error('[pipe] 启动 pipe 服务端失败:', err.message)
+  })
+
+  child.once('exit', (code, signal) => {
+    if (spawnedPipeServer === child) {
+      spawnedPipeServer = null
+    }
+    if (code !== 0 && code !== null) {
+      console.warn('[pipe] 服务端进程退出:', { code, signal })
+    }
+  })
+
+  await waitUntilPipeAccepts(25_000, 200)
+}
+
+export function stopSpawnedPipeServer(): void {
+  const child = spawnedPipeServer
+  if (!child || child.killed) {
+    spawnedPipeServer = null
+    return
+  }
+  try {
+    child.kill()
+  } catch {
+    // ignore
+  }
+  spawnedPipeServer = null
+}

+ 3 - 1
src/renderer/src/locales/en_US.json

@@ -150,5 +150,7 @@
   "qrcode": "QRCode",
   "barcode": "Barcode",
   "baseMeter": "Base Meter",
-  "Meter": "Meter"
+  "Meter": "Meter",
+  "vectorFont": "Vector Font",
+  "fontPackaging": "Font Packging"
 }

+ 3 - 1
src/renderer/src/locales/zh_CN.json

@@ -149,5 +149,7 @@
   "qrcode": "二维码",
   "barcode": "条形码",
   "baseMeter": "基础仪表",
-  "Meter": "仪表"
+  "Meter": "仪表",
+  "vectorFont": "矢量字库",
+  "fontPackaging": "字体存储"
 }

+ 72 - 0
src/renderer/src/store/modules/pipe.ts

@@ -0,0 +1,72 @@
+import { computed, ref } from 'vue'
+import { defineStore } from 'pinia'
+
+type PipeLogLevel = 'info' | 'success' | 'warning' | 'error'
+type PipeLogCategory = 'connection' | 'auth' | 'message'
+type PipeLogDirection = 'system' | 'recv' | 'send'
+
+type PipeRendererEvent = {
+  timestamp: string
+  level: PipeLogLevel
+  category: PipeLogCategory
+  direction: PipeLogDirection
+  message: string
+  detail?: string
+  msgId?: number
+  msgName?: string
+  payloadLength?: number
+  payloadHex?: string
+  payloadText?: string
+  connected: boolean
+  authenticated: boolean
+}
+
+export type PipeLogEntry = PipeRendererEvent & {
+  id: number
+}
+
+const MAX_LOG_COUNT = 500
+
+export const usePipeStore = defineStore('pipe', () => {
+  const logs = ref<PipeLogEntry[]>([])
+  const connected = ref(false)
+  const authenticated = ref(false)
+  const lastEventAt = ref('')
+  const seed = ref(0)
+
+  const statusText = computed(() => {
+    if (authenticated.value) return 'Authenticated'
+    if (connected.value) return 'Connected / Waiting Auth'
+    return 'Disconnected'
+  })
+
+  function append(event: PipeRendererEvent) {
+    connected.value = event.connected
+    authenticated.value = event.authenticated
+    lastEventAt.value = event.timestamp
+    seed.value += 1
+
+    logs.value.push({
+      id: seed.value,
+      ...event
+    })
+
+    if (logs.value.length > MAX_LOG_COUNT) {
+      logs.value.splice(0, logs.value.length - MAX_LOG_COUNT)
+    }
+  }
+
+  function clear() {
+    logs.value = []
+  }
+
+  return {
+    logs,
+    connected,
+    authenticated,
+    lastEventAt,
+    statusText,
+    append,
+    clear
+  }
+})

+ 4 - 0
src/renderer/src/types/appMeta.d.ts

@@ -76,4 +76,8 @@ export interface AppMeta {
   createTime?: string
   // 修改时间
   modifyTime?: string
+  // 字体存储
+  fontPackaging: 'c' | 'bin'
+  // 矢量字库
+  vectorFont: boolean
 }

+ 20 - 1
src/renderer/src/views/designer/modals/projectModal/index.vue

@@ -448,6 +448,23 @@
                 <el-input spellcheck="false" v-model.number="formData.binNum" />
               </el-form-item>
             </el-col>
+          </el-row>
+          <el-row :gutter="12">
+            <el-col :span="8">
+              <el-form-item :label="$t('fontPackaging')">
+                <el-select v-model="formData.fontPackaging">
+                  <el-option :label="$t('cCode')" value="c" />
+                  <el-option label="BIN" value="bin" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item :label="$t('vectorFont')" prop="vectorFont">
+                <el-switch v-model="formData.vectorFont" />
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row>
             <el-col :span="24">
               <el-form-item :label="$t('projectDesc')">
                 <el-input spellcheck="false" type="textarea" v-model="formData.description" />
@@ -586,7 +603,9 @@ const formData = reactive<
   createTime: undefined,
   modifyTime: undefined,
   imageCompress: [],
-  videoFormats: []
+  videoFormats: [],
+  fontPackaging: 'c',
+  vectorFont: false
 })
 
 /**

+ 21 - 0
src/renderer/src/views/designer/sidebar/index.vue

@@ -28,6 +28,27 @@
       </div>
       <div class="w-full flex flex-col">
         <ul class="list-none p-0 m-0 text-12px text-text-secondary">
+          <li class="sidebar-menu-item">
+            <el-popover placement="right" v-model:visible="showPopoverMenu" trigger="click">
+              <template #reference>
+                <span>功能</span>
+              </template>
+              <div class="text-12px" @click="showPopoverMenu = false">
+                <div
+                  class="leading-24px px-12px cursor-pointer hover:bg-accent-blue"
+                  @click="handleProjectSetting"
+                >
+                  多语言
+                </div>
+                <div
+                  class="leading-24px px-12px cursor-pointer hover:bg-accent-blue"
+                  @click="appStore.showGeneralModal = true"
+                >
+                  主题
+                </div>
+              </div>
+            </el-popover>
+          </li>
           <li class="sidebar-menu-item h-32px!" @click="toggleShowEvent">
             <el-tooltip
               content="事件"

+ 297 - 3
src/renderer/src/views/designer/workspace/composite/Log.vue

@@ -1,7 +1,301 @@
 <template>
-  <div class="p-12px text-10px">日志输出</div>
+  <div class="pipe-log">
+    <div class="pipe-log__toolbar">
+      <div class="pipe-log__status">
+        <span class="pipe-log__status-dot" :class="statusClass"></span>
+        <span class="pipe-log__toolbar-text">{{ pipeStore.statusText }}</span>
+        <span class="pipe-log__count">{{ pipeStore.logs.length }} lines</span>
+      </div>
+      <el-button class="pipe-log__clear" text size="small" @click="pipeStore.clear()">
+        Clear
+      </el-button>
+    </div>
+
+    <div ref="listRef" class="pipe-log__terminal" role="log" aria-live="polite">
+      <div v-if="!pipeStore.logs.length" class="pipe-log__empty">
+        <span class="pipe-log__prompt">$</span>
+        <span class="pipe-log__empty-text">Waiting for pipe events…</span>
+      </div>
+
+      <template v-for="item in pipeStore.logs" :key="item.id">
+        <div class="pipe-log__line" :class="[`is-${item.level}`]">
+          <span class="pipe-log__prompt" aria-hidden="true">$</span>
+          <span class="pipe-log__ts">[{{ formatTime(item.timestamp) }}]</span>
+          <span class="pipe-log__dir" :class="`dir-${item.direction}`">{{
+            padDirection(getDirectionLabel(item.direction))
+          }}</span>
+          <span class="pipe-log__cat">{{ padCategory(getCategoryLabel(item.category)) }}</span>
+          <span v-if="item.msgId !== undefined" class="pipe-log__msgid">
+            {{ item.msgName || 'UNKNOWN' }}/0x{{
+              item.msgId.toString(16).toUpperCase().padStart(4, '0')
+            }}
+          </span>
+          <span class="pipe-log__body">{{ item.message }}</span>
+        </div>
+        <pre v-if="item.detail" class="pipe-log__detail">{{ item.detail }}</pre>
+      </template>
+    </div>
+  </div>
 </template>
 
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { computed, nextTick, ref, watch } from 'vue'
+import { usePipeStore } from '@/store/modules/pipe'
+
+const pipeStore = usePipeStore()
+const listRef = ref<HTMLDivElement>()
+
+const statusClass = computed(() => {
+  if (pipeStore.authenticated) return 'is-authenticated'
+  if (pipeStore.connected) return 'is-connected'
+  return 'is-idle'
+})
+
+watch(
+  () => pipeStore.logs.length,
+  async () => {
+    await nextTick()
+    if (listRef.value) {
+      listRef.value.scrollTop = listRef.value.scrollHeight
+    }
+  }
+)
+
+function formatTime(timestamp: string) {
+  if (!timestamp) return '--:--:--'
+  return new Date(timestamp).toLocaleTimeString('en-GB', { hour12: false })
+}
+
+function getDirectionLabel(direction: 'system' | 'recv' | 'send') {
+  if (direction === 'recv') return 'Recv'
+  if (direction === 'send') return 'Send'
+  return 'System'
+}
+
+function getCategoryLabel(category: 'connection' | 'auth' | 'message') {
+  if (category === 'connection') return 'Connection'
+  if (category === 'auth') return 'Auth'
+  return 'Message'
+}
+
+function padDirection(label: string) {
+  return label.padEnd(6, ' ')
+}
+
+function padCategory(label: string) {
+  return label.padEnd(10, ' ')
+}
+</script>
+
+<style scoped>
+.pipe-log {
+  --log-term-fg: #c9d1d9;
+  --log-term-dim: #6e7681;
+  --log-term-border: #30363d;
+  --log-term-info: #58a6ff;
+  --log-term-success: #3fb950;
+  --log-term-warning: #d29922;
+  --log-term-error: #f85149;
+  --log-term-prompt: #7ee787;
+  --log-term-send: #79c0ff;
+  --log-term-recv: #ffa657;
+
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+
+  color: var(--log-term-fg);
+  font-family:
+    ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
+  font-size: 12px;
+  line-height: 1.45;
+}
+
+.pipe-log__toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  flex-shrink: 0;
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--log-term-border);
+}
+
+.pipe-log__status {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+}
+
+.pipe-log__toolbar-text {
+  color: var(--log-term-fg);
+}
+
+.pipe-log__status-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 999px;
+  background: #484f58;
+  flex-shrink: 0;
+}
+
+.pipe-log__status-dot.is-connected {
+  background: var(--log-term-warning);
+  box-shadow: 0 0 6px color-mix(in srgb, var(--log-term-warning) 55%, transparent);
+}
+
+.pipe-log__status-dot.is-authenticated {
+  background: var(--log-term-success);
+  box-shadow: 0 0 6px color-mix(in srgb, var(--log-term-success) 45%, transparent);
+}
+
+.pipe-log__count {
+  color: var(--log-term-dim);
+  margin-left: 4px;
+}
+
+.pipe-log__clear {
+  color: var(--log-term-info) !important;
+}
+
+.pipe-log__clear:hover {
+  color: #79b8ff !important;
+}
+
+.pipe-log__terminal {
+  flex: 1;
+  overflow: auto;
+  padding: 10px 12px 14px;
+  min-height: 0;
+  tab-size: 2;
+}
+
+.pipe-log__terminal::-webkit-scrollbar {
+  width: 10px;
+  height: 10px;
+}
+
+.pipe-log__terminal::-webkit-scrollbar-track {
+  background: #010409;
+}
+
+.pipe-log__terminal::-webkit-scrollbar-thumb {
+  background: #484f58;
+  border-radius: 5px;
+}
+
+.pipe-log__empty {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  height: 100%;
+  min-height: 120px;
+  padding-left: 4px;
+  color: var(--log-term-dim);
+}
+
+.pipe-log__empty-text {
+  font-style: italic;
+}
+
+.pipe-log__line {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  gap: 0.4em 0.65em;
+  margin: 0 0 2px;
+  padding: 2px 0;
+  border-left: 2px solid transparent;
+  padding-left: 6px;
+  margin-left: -6px;
+}
+
+.pipe-log__line.is-info {
+  border-left-color: color-mix(in srgb, var(--log-term-info) 55%, transparent);
+}
+
+.pipe-log__line.is-success {
+  border-left-color: color-mix(in srgb, var(--log-term-success) 60%, transparent);
+}
+
+.pipe-log__line.is-warning {
+  border-left-color: color-mix(in srgb, var(--log-term-warning) 65%, transparent);
+}
+
+.pipe-log__line.is-error {
+  border-left-color: color-mix(in srgb, var(--log-term-error) 70%, transparent);
+}
+
+.pipe-log__prompt {
+  color: var(--log-term-prompt);
+  font-weight: 600;
+  user-select: none;
+  margin-right: 2px;
+}
+
+.pipe-log__ts {
+  color: var(--log-term-dim);
+  flex-shrink: 0;
+}
+
+.pipe-log__dir {
+  color: var(--log-term-dim);
+  flex-shrink: 0;
+  letter-spacing: 0.02em;
+}
+
+.pipe-log__dir.dir-send {
+  color: var(--log-term-send);
+}
+
+.pipe-log__dir.dir-recv {
+  color: var(--log-term-recv);
+}
+
+.pipe-log__cat {
+  color: #a371f7;
+  flex-shrink: 0;
+}
+
+.pipe-log__msgid {
+  color: #8b949e;
+  flex-shrink: 0;
+}
+
+.pipe-log__body {
+  color: var(--log-term-fg);
+  word-break: break-word;
+  min-width: 0;
+}
+
+.pipe-log__line.is-success .pipe-log__body {
+  color: #56d364;
+}
+
+.pipe-log__line.is-warning .pipe-log__body {
+  color: #e3b341;
+}
+
+.pipe-log__line.is-error .pipe-log__body {
+  color: #ff7b72;
+}
+
+.pipe-log__line.is-info .pipe-log__body {
+  color: #79c0ff;
+}
 
-<style scoped></style>
+.pipe-log__detail {
+  margin: 0 0 8px 18px;
+  padding: 6px 10px;
+  white-space: pre-wrap;
+  word-break: break-all;
+  line-height: 1.5;
+  color: #8b949e;
+  background: #010409;
+  border: 1px solid var(--log-term-border);
+  border-radius: 4px;
+  font-family: inherit;
+  font-size: 11.5px;
+}
+</style>

+ 16 - 10
src/renderer/src/views/designer/workspace/index.vue

@@ -64,10 +64,11 @@
 <script setup lang="ts">
 import type { TabPaneName } from 'element-plus'
 
-import { ref, onMounted, shallowRef, watch } from 'vue'
+import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
 import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
 import { useProjectStore } from '@/store/modules/project'
 import { useAppStore } from '@/store/modules/app'
+import { usePipeStore } from '@/store/modules/pipe'
 
 import MonacoEditor from '@/components/MonacoEditor/index.vue'
 import Stage from './stage/index.vue'
@@ -81,6 +82,7 @@ type TabItem = {
 
 const projectStore = useProjectStore()
 const appStore = useAppStore()
+const pipeStore = usePipeStore()
 // panel ref
 const screen1Ref = ref<InstanceType<typeof SplitterPanel>>()
 const screen2Ref = ref<InstanceType<typeof SplitterPanel>>()
@@ -88,8 +90,8 @@ const screen2Ref = ref<InstanceType<typeof SplitterPanel>>()
 const stage1Ref = ref<InstanceType<typeof Stage>>()
 const stage2Ref = ref<InstanceType<typeof Stage>>()
 
-const content = ref('')
 const activeTab = ref<TabPaneName>('design')
+let removePipeListener: (() => void) | undefined
 // tab pane列表
 const tabPaneList = shallowRef<TabItem[]>([
   {
@@ -99,16 +101,20 @@ const tabPaneList = shallowRef<TabItem[]>([
   }
 ])
 onMounted(() => {
-  window.electron.ipcRenderer.send('connect-pipe')
+  removePipeListener = window.electron.ipcRenderer.on(
+    'pipe-event',
+    (_event, data) => {
+      pipeStore.append(data)
+    }
+  )
 
-  window.electron.ipcRenderer.on('pipe-message', (_event, data) => {
-    console.log('pipe message', data)
-    content.value += data.toString() + '\n'
-  })
+  window.electron.ipcRenderer.send('connect-pipe')
+})
 
-  window.electron.ipcRenderer.on('pipe-connected', () => {
-    console.log('pipe connected')
-  })
+onUnmounted(() => {
+  removePipeListener?.()
+  removePipeListener = undefined
+  window.electron.ipcRenderer.send('disconnect-pipe')
 })
 
 // 关闭tab