liuq 1 mesiac pred
rodič
commit
212a8c338f

+ 19 - 5
README.md

@@ -1,10 +1,10 @@
-# 韫珠IM (WeChat Clone)
+# 韫珠IM
 
 这是一个使用 Electron 和 React 构建的仿微信 PC 客户端桌面应用。
 
 ## 📋 项目概述
 
-**项目名称**: 韫珠IM (WeChat Clone)  
+**项目名称**: 韫珠IM  
 **项目类型**: 基于 Electron 的桌面即时通讯客户端  
 **技术栈**: Electron + React + TypeScript + Vite
 
@@ -162,7 +162,8 @@ npm run dist
 - **appId**: `com.hnyunzhu.im`
 - **productName**: `韫珠IM`
 - **输出目录**: `dist/`
-- **应用图标**: `app_icon.png`(所有平台)
+- **应用图标**: Windows 使用 `resources/icon.ico`,macOS/Linux 使用 `resources/logo.png`
+- **afterPack**: `scripts/afterPack.js`(通过 rcedit 将图标嵌入 Windows exe)
 - **Windows**: 生成 NSIS 安装程序,支持自定义安装路径
 - **macOS**: 生成 DMG 镜像,支持 x64 和 arm64 架构
 - **Linux**: 生成 AppImage 格式
@@ -290,12 +291,24 @@ WebSocket 服务位于 `src/renderer/src/services/websocket.ts`,支持:
    - 确保有足够的磁盘空间(至少 500MB)
    - Windows 打包需要安装 NSIS(electron-builder 会自动处理)
    - **图标问题**: 如果遇到 `image must be at least 256x256` 错误,说明图标尺寸不够,需要准备至少 256x256 像素的图标文件,或暂时移除图标配置
-   - **代码签名问题**: Windows 打包时如果遇到符号链接权限错误,这是因为 electron-builder 尝试下载代码签名工具。当前配置已通过 `forceCodeSigning: false`、`signAndEditExecutable: false` 和环境变量禁用代码签名。
+   - **代码签名问题**: Windows 打包时如果遇到符号链接权限错误,这是因为 electron-builder 尝试下载代码签名工具(`winCodeSign`),其压缩包中包含 macOS 符号链接文件,在非管理员模式下解压会失败。当前配置已通过 `forceCodeSigning: false`、`signAndEditExecutable: false` 和环境变量禁用代码签名。
    - **备选方案**: 如果 NSIS 安装程序打包失败,可以:
      1. 使用 `npm run dist:portable` 生成便携版(不需要签名)
      2. 直接使用 `dist/win-unpacked/韫珠IM.exe` 运行应用
      3. 以管理员权限运行 PowerShell 后再打包(解决符号链接权限问题)
 
+7. **Windows exe 图标嵌入机制**:
+   - `signAndEditExecutable: false` 会导致 electron-builder 跳过图标嵌入步骤,exe 文件和任务栏将无法显示自定义图标
+   - 项目通过 `afterPack` 钩子 (`scripts/afterPack.js`) 使用 `rcedit` 手动将 `resources/icon.ico` 嵌入到 exe 中,绕过了 `winCodeSign` 的下载问题
+   - **不要删除** `scripts/afterPack.js` 文件和 `rcedit` 依赖,否则打包后 exe 和任务栏将没有图标
+   - 更换图标时,替换 `resources/icon.ico` 即可。ico 文件建议包含多尺寸(16x16、32x32、48x48、256x256),Windows 在不同场景使用不同尺寸:
+     - 16x16:任务栏、窗口标题栏
+     - 32x32:桌面快捷方式(小图标)
+     - 48x48:资源管理器
+     - 256x256:资源管理器大图标视图
+   - 托盘图标由 `src/main/index.ts` 中的 `Tray` 在运行时从文件加载,不依赖 exe 嵌入
+   - 安装程序图标由 NSIS 单独处理,不受 `signAndEditExecutable` 影响
+
 ---
 
 ## 📝 开发脚本说明
@@ -304,7 +317,8 @@ WebSocket 服务位于 `src/renderer/src/services/websocket.ts`,支持:
 - `npm run build`: 构建项目(编译 TypeScript,打包资源)
 - `npm run preview`: 预览构建结果
 - `npm run pack`: 仅打包(不重新构建)
-- `npm run dist`: 构建并打包(推荐用于生产环境)
+- `npm run dist`: 构建并打包 NSIS 安装程序(推荐用于生产环境)
+- `npm run dist:portable`: 构建并打包便携版(单 exe,无需安装)
 
 ---
 

+ 21 - 7
package.json

@@ -1,5 +1,5 @@
 {
-  "name": "wechat-client",
+  "name": "yunzhu-im",
   "version": "1.0.0",
   "main": "./out/main/index.js",
   "author": "example.com",
@@ -28,9 +28,10 @@
     "@types/react-dom": "^18.0.11",
     "@vitejs/plugin-react": "^4.0.0",
     "cross-env": "^10.1.0",
-    "electron": "^28.0.0",
-    "electron-builder": "^26.8.1",
+    "electron": "^39.0.0",
+    "electron-builder": "^26.8.2",
     "electron-vite": "^2.0.0",
+    "rcedit": "^5.0.2",
     "sharp": "^0.34.5",
     "to-ico": "^1.1.5",
     "typescript": "^5.0.2",
@@ -44,9 +45,18 @@
     },
     "files": [
       "out/**/*",
-      "resources/**/*",
       "package.json"
     ],
+    "afterPack": "./scripts/afterPack.js",
+    "extraResources": [
+      {
+        "from": "resources",
+        "to": ".",
+        "filter": [
+          "**/*"
+        ]
+      }
+    ],
     "win": {
       "target": [
         {
@@ -58,8 +68,7 @@
       ],
       "icon": "resources/icon.ico",
       "forceCodeSigning": false,
-      "signAndEditExecutable": false,
-      "signExts": []
+      "signAndEditExecutable": false
     },
     "mac": {
       "target": [
@@ -88,7 +97,12 @@
       "oneClick": false,
       "allowToChangeInstallationDirectory": true,
       "createDesktopShortcut": true,
-      "createStartMenuShortcut": true
+      "createStartMenuShortcut": true,
+      "installerLanguages": [
+        "zh_CN",
+        "en_US"
+      ],
+      "language": 2052
     }
   }
 }

BIN
resources/logo.png


+ 25 - 0
scripts/afterPack.js

@@ -0,0 +1,25 @@
+const path = require('path')
+const { rcedit } = require('rcedit')
+
+exports.default = async function afterPack(context) {
+  if (context.electronPlatformName !== 'win32') return
+
+  const exePath = path.join(
+    context.appOutDir,
+    `${context.packager.appInfo.productFilename}.exe`
+  )
+  const iconPath = path.resolve(__dirname, '../resources/icon.ico')
+
+  console.log(`[afterPack] Setting icon for ${exePath}`)
+
+  await rcedit(exePath, {
+    icon: iconPath,
+    'version-string': {
+      ProductName: context.packager.appInfo.productName,
+      FileDescription: context.packager.appInfo.productName,
+      CompanyName: context.packager.appInfo.companyName || '',
+    },
+  })
+
+  console.log('[afterPack] Icon embedded successfully')
+}

+ 66 - 51
src/main/index.ts

@@ -1,4 +1,4 @@
-import { app, shell, BrowserWindow, ipcMain, Tray, Menu, nativeImage, BrowserView, IpcMainEvent, dialog } from 'electron'
+import { app, shell, BrowserWindow, ipcMain, Tray, Menu, nativeImage, WebContentsView, IpcMainEvent, dialog } from 'electron'
 import { join } from 'path'
 import { electronApp, optimizer, is } from '@electron-toolkit/utils'
 import { mkdirSync, appendFileSync, existsSync, writeFileSync } from 'fs'
@@ -7,20 +7,23 @@ import https from 'https'
 import http from 'http'
 
 // --- 日志管理 ---
-const logDir = join(process.cwd(), 'logs')
-if (!existsSync(logDir)) {
-  mkdirSync(logDir, { recursive: true })
+let logDir: string = ''
+
+function ensureLogDir(): void {
+  if (!logDir) {
+    logDir = join(app.getPath('userData'), 'logs')
+  }
+  if (!existsSync(logDir)) {
+    mkdirSync(logDir, { recursive: true })
+  }
 }
 
 function writeLog(message: string) {
+  ensureLogDir()
   const date = new Date()
   const fileName = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}.log`
   const logPath = join(logDir, fileName)
-  
-  // Format: [HH:mm:ss] Message\n
-  // If the message already has a timestamp from the renderer, we append it directly.
-  // Renderer logger adds [timestamp] [level].
-  
+
   try {
     appendFileSync(logPath, message + '\n', 'utf-8')
   } catch (err) {
@@ -45,21 +48,21 @@ interface TabInfo {
   id: string
   url: string
   title: string
-  view: BrowserView
+  view: WebContentsView
 }
 
 class ViewManager {
   private window: BrowserWindow
   private tabs: Map<string, TabInfo> = new Map()
   private activeTabId: string | null = null
-  private bounds: Electron.Rectangle = { x: 0, y: 78, width: 1024, height: 600 } // 初始默认值
+  private bounds: Electron.Rectangle = { x: 0, y: 78, width: 1024, height: 600 }
 
   constructor(window: BrowserWindow) {
     this.window = window
   }
 
   createTab(url: string, active: boolean = true): string {
-    const view = new BrowserView({
+    const view = new WebContentsView({
       webPreferences: {
         sandbox: false,
         nodeIntegration: false,
@@ -68,20 +71,19 @@ class ViewManager {
     })
 
     const id = Date.now().toString() + Math.random().toString(36).substr(2, 9)
-    
-    // 转发事件到渲染进程
+
     view.webContents.on('did-start-loading', () => {
       if (!this.window.isDestroyed()) {
         this.window.webContents.send('tab-update', id, { isLoading: true })
       }
     })
-    
+
     view.webContents.on('did-stop-loading', () => {
       if (!this.window.isDestroyed()) {
-        this.window.webContents.send('tab-update', id, { 
+        this.window.webContents.send('tab-update', id, {
           isLoading: false,
-          canGoBack: view.webContents.canGoBack(),
-          canGoForward: view.webContents.canGoForward(),
+          canGoBack: view.webContents.navigationHistory.canGoBack(),
+          canGoForward: view.webContents.navigationHistory.canGoForward(),
           url: view.webContents.getURL(),
           title: view.webContents.getTitle()
         })
@@ -93,15 +95,14 @@ class ViewManager {
         this.window.webContents.send('tab-update', id, { title })
       }
     })
-    
+
     view.webContents.on('did-navigate', (_, url) => {
-        if (!this.window.isDestroyed()) {
-          this.window.webContents.send('tab-update', id, { url })
-        }
+      if (!this.window.isDestroyed()) {
+        this.window.webContents.send('tab-update', id, { url })
+      }
     })
 
     view.webContents.setWindowOpenHandler((details) => {
-      // 拦截新窗口,在当前窗口新建标签
       const newId = this.createTab(details.url, true)
       if (!this.window.isDestroyed()) {
         this.window.webContents.send('tab-created', { id: newId, url: details.url, title: 'Loading...' })
@@ -110,11 +111,11 @@ class ViewManager {
     })
 
     try {
-        view.webContents.loadURL(url)
+      view.webContents.loadURL(url)
     } catch (e) {
-        console.error('Failed to load URL:', url, e)
+      console.error('Failed to load URL:', url, e)
     }
-    
+
     this.tabs.set(id, { id, url, title: 'Loading...', view })
 
     if (active) {
@@ -128,19 +129,16 @@ class ViewManager {
     const tab = this.tabs.get(id)
     if (!tab) return
 
-    // 移除旧视图
     if (this.activeTabId) {
       const currentTab = this.tabs.get(this.activeTabId)
       if (currentTab) {
-        this.window.removeBrowserView(currentTab.view)
+        this.window.contentView.removeChildView(currentTab.view)
       }
     }
 
-    // 设置新视图
-    this.window.setBrowserView(tab.view)
+    this.window.contentView.addChildView(tab.view)
     tab.view.setBounds(this.bounds)
-    tab.view.setAutoResize({ width: true, height: true })
-    
+
     this.activeTabId = id
   }
 
@@ -149,13 +147,13 @@ class ViewManager {
     if (!tab) return
 
     if (this.activeTabId === id) {
-      this.window.removeBrowserView(tab.view)
+      this.window.contentView.removeChildView(tab.view)
       this.activeTabId = null
+    } else {
+      this.window.contentView.removeChildView(tab.view)
     }
-    
-    // 销毁
-    // 官方文档推荐用这种方式销毁 BrowserView 的内容
-    (tab.view.webContents as any).destroy()
+
+    tab.view.webContents.close()
     this.tabs.delete(id)
   }
 
@@ -170,31 +168,32 @@ class ViewManager {
   }
 
   goBack() {
-    if (this.activeTabId) this.tabs.get(this.activeTabId)?.view.webContents.goBack()
+    if (this.activeTabId) this.tabs.get(this.activeTabId)?.view.webContents.navigationHistory.goBack()
   }
-  
+
   goForward() {
-    if (this.activeTabId) this.tabs.get(this.activeTabId)?.view.webContents.goForward()
+    if (this.activeTabId) this.tabs.get(this.activeTabId)?.view.webContents.navigationHistory.goForward()
   }
-  
+
   reload() {
     if (this.activeTabId) this.tabs.get(this.activeTabId)?.view.webContents.reload()
   }
-  
+
   loadURL(id: string, url: string) {
     const tab = this.tabs.get(id)
     if (tab) {
-        tab.view.webContents.loadURL(url)
+      tab.view.webContents.loadURL(url)
     }
   }
 
   destroy() {
-      this.tabs.forEach(tab => {
-          try {
-             (tab.view.webContents as any).destroy()
-          } catch(e) {}
-      })
-      this.tabs.clear()
+    this.tabs.forEach(tab => {
+      try {
+        this.window.contentView.removeChildView(tab.view)
+        tab.view.webContents.close()
+      } catch (e) { /* already destroyed */ }
+    })
+    this.tabs.clear()
   }
 }
 
@@ -311,7 +310,7 @@ function createWindow(): void {
     titleBarStyle: 'hidden', // Hide title bar
     titleBarOverlay: {
       color: '#ffffff', // Match login background
-      symbolColor: '#747474', // Match WeChat control color
+      symbolColor: '#747474', // Match control color
       height: 30
     },
     webPreferences: {
@@ -1014,8 +1013,22 @@ ipcMain.on('browser-action', (event, action) => {
     }
 })
 
+const gotTheLock = app.requestSingleInstanceLock()
+
+if (!gotTheLock) {
+  app.quit()
+} else {
+
+app.on('second-instance', () => {
+  if (mainWindow) {
+    if (mainWindow.isMinimized()) mainWindow.restore()
+    if (!mainWindow.isVisible()) mainWindow.show()
+    mainWindow.focus()
+  }
+})
+
 app.whenReady().then(() => {
-  electronApp.setAppUserModelId('com.electron')
+  electronApp.setAppUserModelId('com.hnyunzhu.im')
   app.on('browser-window-created', (_, window) => {
     optimizer.watchWindowShortcuts(window)
   })
@@ -1291,3 +1304,5 @@ app.on('window-all-closed', () => {
     app.quit()
   }
 })
+
+} // end of gotTheLock else

+ 1 - 1
src/renderer/src/App.tsx

@@ -544,7 +544,7 @@ function App(): JSX.Element {
         </div>
       )}
 
-      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', backgroundColor: '#f5f5f5' }}>
+      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', backgroundColor: '#f5f5f5', minWidth: 0, overflow: 'hidden' }}>
         {activeTab === 'chat' ? (
           isLoadingContacts ? (
             <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>

+ 1 - 0
src/renderer/src/components/ChatWindow/MessageBubble.tsx

@@ -242,6 +242,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
           display: 'flex', 
           alignItems: 'center', 
           minWidth: '200px',
+          maxWidth: '300px',
           padding: '8px',
           backgroundColor: '#f5f5f5',
           borderRadius: '4px',

+ 4 - 4
src/renderer/src/pages/ChatPage.tsx

@@ -155,7 +155,7 @@ export const ChatPage: React.FC<ChatPageProps> = ({
       
       <div 
         ref={chatContainerRef}
-        style={{ flex: 1, padding: '20px', overflowY: 'auto' }}
+        style={{ flex: 1, padding: '20px', overflowY: 'auto', overflowX: 'hidden' }}
       >
         {isLoadingMessages && activeContactId && !messages[activeContactId] ? (
           <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#999' }}>
@@ -216,12 +216,12 @@ export const ChatPage: React.FC<ChatPageProps> = ({
                   width: '100%'
                 }}>
                   {!msg.isSelf && (
-                    <div style={{ width: '35px', height: '35px', borderRadius: '4px', marginRight: '10px', overflow: 'hidden', cursor: 'pointer', fontSize: '35px' }}>
+                    <div style={{ width: '35px', height: '35px', borderRadius: '4px', marginRight: '10px', overflow: 'hidden', cursor: 'pointer', fontSize: '35px', flexShrink: 0 }}>
                       {activeContact.avatar}
                     </div>
                   )}
                   
-                  <div style={{ maxWidth: '70%', position: 'relative' }}>
+                  <div style={{ maxWidth: '70%', position: 'relative', minWidth: 0, overflow: 'hidden' }}>
                     <div
                       onContextMenu={(e) => onContextMenu(e, msg.id)}
                       className={`${msg.isSelf ? 'bubble-self' : 'bubble-other'} ${isMediaMessage ? 'bubble-media' : ''}`}
@@ -249,7 +249,7 @@ export const ChatPage: React.FC<ChatPageProps> = ({
                   </div>
 
                   {msg.isSelf && (
-                    <div style={{ width: '35px', height: '35px', borderRadius: '4px', marginLeft: '10px', overflow: 'hidden', cursor: 'pointer', fontSize: '35px' }}>
+                    <div style={{ width: '35px', height: '35px', borderRadius: '4px', marginLeft: '10px', overflow: 'hidden', cursor: 'pointer', fontSize: '35px', flexShrink: 0 }}>
                       {getDefaultAvatar(currentUserId || 0, currentUserName)}
                     </div>
                   )}