소스 검색

自动更新

liuq 1 개월 전
부모
커밋
cf05e3ebf5
6개의 변경된 파일340개의 추가작업 그리고 18개의 파일을 삭제
  1. 63 0
      README.md
  2. 6 1
      package.json
  3. 38 1
      src/main/index.ts
  4. 128 0
      src/renderer/src/App.tsx
  5. 33 15
      src/renderer/src/components/AppMappingList.tsx
  6. 72 1
      src/renderer/src/components/Modals/SettingsModal.tsx

+ 63 - 0
README.md

@@ -17,6 +17,7 @@
 5. **媒体预览**: 独立的图片/视频预览窗口
 6. **消息搜索**: 全局搜索和聊天内搜索功能
 7. **日志系统**: 完整的本地日志记录功能
+8. **自动更新**: 启动与定时检测、后台下载、进度显示、更新说明(releaseNotes)、下载完成后强制重启更新
 
 ---
 
@@ -185,6 +186,68 @@ npm run dist -- --linux
 
 ---
 
+## 🔄 自动更新
+
+应用使用 `electron-updater` 配合自建更新服务器实现自动更新,仅在生产环境生效(开发模式不检测更新)。
+
+### 更新策略
+
+- **启动时**:应用启动后自动检测一次更新;若有新版本则自动在后台下载。
+- **运行中**:每隔 **5 小时** 自动检测一次更新,有更新则自动下载。
+- **手动检测**:在「设置」中可点击「检查更新」随时触发一次检测。
+- **下载完成后**:弹出强制更新弹窗,仅提供「立即重启并更新」,必须重启以完成安装;下载过程中在界面顶部显示下载进度条。
+
+### 配置说明
+
+更新地址在 `package.json` 的 `build.publish` 中配置:
+
+```json
+"publish": {
+  "provider": "generic",
+  "url": "https://api.hnyunzhu.com:9004/app-updates/1772806127/windows/update/"
+}
+```
+
+客户端会请求该 URL 下的 `latest.yml` 判断是否有新版本。
+
+### 发布新版本流程
+
+1. **修改版本号**:在 `package.json` 中将 `version` 改为新版本号(如 `1.0.5`)。
+2. **打包**:执行 `npm run dist`,在 `dist/` 目录得到:
+   - `韫珠IM Setup x.x.x.exe`(安装包)
+   - `latest.yml`(更新元数据,electron-updater 据此检测与下载)
+3. **(可选)填写更新说明**:编辑 `dist/latest.yml`,在末尾增加 `releaseNotes` 字段,应用会在「有更新」和「下载完成」时展示该内容:
+
+   ```yaml
+   releaseNotes: |
+     1. 修复若干问题
+     2. 新增 xxx 功能
+     3. 优化性能
+   ```
+
+   单行可写为:`releaseNotes: "1. 修复若干问题;2. 新增 xxx 功能"`
+4. **上传**:将 `dist/latest.yml` 和 `dist/韫珠IM Setup x.x.x.exe`(及同名的 `.exe.blockmap`,若有)上传到上述 `publish.url` 对应目录,保证可通过 `https://api.hnyunzhu.com:9004/app-updates/1772806127/windows/update/latest.yml` 和同目录下的 exe 被访问。
+
+### 服务端要求
+
+- 需支持 HTTPS(当前为端口 9004)。
+- 安装包响应建议带 **Content-Length** 头,否则下载进度可能无法正确显示百分比。
+- 无需 CORS(更新请求在主进程内发起)。
+
+### 用户端表现
+
+- **发现新版本**:顶部出现绿色条「正在下载更新…」及进度条与百分比;可继续使用应用(后台下载)。
+- **设置中**:「检查更新」按钮可手动检测;若处于下载中,会显示进度条与更新说明(若 `latest.yml` 中有 `releaseNotes`)。
+- **下载完成**:弹出「新版本已下载,请重启以完成更新」弹窗,可展示本次版本的更新说明,仅可点击「立即重启并更新」完成安装。
+
+### 相关依赖与代码
+
+- **依赖**:`electron-updater`(见 `package.json` 的 `dependencies`)。
+- **主进程**:`src/main/index.ts` 中注册 `autoUpdater` 事件、`check-for-updates` IPC、5 小时定时检测及 `quit-and-install`。
+- **渲染进程**:`src/renderer/src/App.tsx` 中监听更新事件、展示下载进度与强制更新弹窗;`src/renderer/src/components/Modals/SettingsModal.tsx` 中「检查更新」按钮与状态展示。
+
+---
+
 ## 🏗️ 技术架构
 
 ### 进程架构

+ 6 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yunzhu-im",
-  "version": "1.0.0",
+  "version": "1.0.7",
   "main": "./out/main/index.js",
   "author": "example.com",
   "license": "MIT",
@@ -16,6 +16,7 @@
     "@electron-toolkit/preload": "^3.0.0",
     "@electron-toolkit/utils": "^3.0.0",
     "crypto-js": "^4.2.0",
+    "electron-updater": "^6.3.9",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-icons": "^5.5.0"
@@ -40,6 +41,10 @@
   "build": {
     "appId": "com.hnyunzhu.im",
     "productName": "韫珠IM",
+    "publish": {
+      "provider": "generic",
+      "url": "https://api.hnyunzhu.com:9004/app-updates/1772806127/windows/update/"
+    },
     "directories": {
       "output": "dist"
     },

+ 38 - 1
src/main/index.ts

@@ -1,5 +1,6 @@
 import { app, shell, BrowserWindow, ipcMain, Tray, Menu, nativeImage, WebContentsView, IpcMainEvent, dialog, screen } from 'electron'
 import { join } from 'path'
+import { autoUpdater } from 'electron-updater'
 import { electronApp, optimizer, is } from '@electron-toolkit/utils'
 import { mkdirSync, appendFileSync, existsSync, writeFileSync } from 'fs'
 import { writeFile } from 'fs/promises'
@@ -1261,7 +1262,43 @@ app.whenReady().then(() => {
   })
 
   ipcMain.on('ping', () => console.log('pong'))
-  
+
+  // 生产环境自动更新(自建服务器)
+  if (!is.dev) {
+    autoUpdater.checkForUpdatesAndNotify()
+    autoUpdater.on('update-available', (info) => {
+      mainWindow?.webContents.send('update-available', info)
+    })
+    autoUpdater.on('update-downloaded', () => {
+      mainWindow?.webContents.send('update-downloaded')
+    })
+    autoUpdater.on('download-progress', (info: { percent: number; transferred: number; total: number; bytesPerSecond: number }) => {
+      mainWindow?.webContents.send('update-download-progress', {
+        percent: info.percent,
+        transferred: info.transferred,
+        total: info.total,
+        bytesPerSecond: info.bytesPerSecond
+      })
+    })
+    autoUpdater.on('update-not-available', () => {
+      mainWindow?.webContents.send('update-not-available')
+    })
+    autoUpdater.on('error', (err) => {
+      mainWindow?.webContents.send('update-error', err.message)
+    })
+    // 运行时每 5 小时自动检测一次更新
+    const FIVE_HOURS_MS = 5 * 60 * 60 * 1000
+    setInterval(() => {
+      autoUpdater.checkForUpdates()
+    }, FIVE_HOURS_MS)
+  }
+  ipcMain.on('check-for-updates', () => {
+    if (!is.dev) autoUpdater.checkForUpdates()
+  })
+  ipcMain.on('quit-and-install', () => {
+    autoUpdater.quitAndInstall(false, true)
+  })
+
   ipcMain.on('login-success', () => {
     if (mainWindow) {
       mainWindow.setSize(1000, 700)

+ 128 - 0
src/renderer/src/App.tsx

@@ -25,6 +25,11 @@ function App(): JSX.Element {
   const [selectedContactDetail, setSelectedContactDetail] = useState<UserContact | null>(null)
   const [showSettings, setShowSettings] = useState(false)
   const [showChatSearchModal, setShowChatSearchModal] = useState(false)
+  const [showForceUpdateModal, setShowForceUpdateModal] = useState(false)
+  const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'update-available' | 'update-downloaded' | 'latest' | 'error'>('idle')
+  const [updateError, setUpdateError] = useState('')
+  const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
+  const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null)
   const [inputValue, setInputValue] = useState('')
   const [contextMenu, setContextMenu] = useState<{ x: number, y: number, msgId: number } | null>(null)
   
@@ -75,6 +80,49 @@ function App(): JSX.Element {
     fetchContacts
   })
 
+  // 监听自动更新事件(检测到更新则必须更新,支持后台下载并显示进度)
+  useEffect(() => {
+    if (!window.electron?.ipcRenderer) return
+    const onUpdateAvailable = (_: any, info: { version?: string; releaseNotes?: string | null }) => {
+      setUpdateStatus('update-available')
+      setUpdateDownloadProgress(0)
+      const notes = info?.releaseNotes != null ? String(info.releaseNotes).trim() : ''
+      setUpdateInfo({
+        version: info?.version ?? '',
+        releaseNotes: notes
+      })
+    }
+    const onUpdateDownloaded = () => {
+      setUpdateStatus('update-downloaded')
+      setUpdateDownloadProgress(null)
+      setShowForceUpdateModal(true)
+    }
+    const onUpdateDownloadProgress = (_: any, info: { percent: number }) => {
+      setUpdateDownloadProgress(info.percent)
+    }
+    const onUpdateNotAvailable = () => {
+      setUpdateStatus('latest')
+      setUpdateDownloadProgress(null)
+    }
+    const onUpdateError = (_: any, message: string) => {
+      setUpdateStatus('error')
+      setUpdateError(message || '检查更新失败')
+      setUpdateDownloadProgress(null)
+    }
+    window.electron.ipcRenderer.on('update-available', onUpdateAvailable)
+    window.electron.ipcRenderer.on('update-downloaded', onUpdateDownloaded)
+    window.electron.ipcRenderer.on('update-download-progress', onUpdateDownloadProgress)
+    window.electron.ipcRenderer.on('update-not-available', onUpdateNotAvailable)
+    window.electron.ipcRenderer.on('update-error', onUpdateError)
+    return () => {
+      window.electron.ipcRenderer.removeAllListeners('update-available')
+      window.electron.ipcRenderer.removeAllListeners('update-downloaded')
+      window.electron.ipcRenderer.removeAllListeners('update-download-progress')
+      window.electron.ipcRenderer.removeAllListeners('update-not-available')
+      window.electron.ipcRenderer.removeAllListeners('update-error')
+    }
+  }, [])
+
   // 监听切换联系人事件
   useEffect(() => {
     const handleSwitchContact = (_: any, contactId: number) => {
@@ -102,6 +150,13 @@ function App(): JSX.Element {
     }
   }, [activeTab, selectedContactDetail])
 
+  // 处理发送消息
+  const handleCheckForUpdates = useCallback(() => {
+    setUpdateError('')
+    setUpdateStatus('checking')
+    window.electron?.ipcRenderer?.send('check-for-updates')
+  }, [])
+
   // 处理发送消息
   const handleSend = useCallback(async () => {
     if (!activeContactId) return
@@ -696,8 +751,81 @@ function App(): JSX.Element {
         currentUserId={currentUserId}
         currentUserName={currentUserName}
         onLogout={handleLogout}
+        updateStatus={updateStatus}
+        updateError={updateError}
+        updateDownloadProgress={updateDownloadProgress}
+        onCheckForUpdates={handleCheckForUpdates}
       />
 
+      {/* 强制更新弹窗:检测到更新后必须重启安装,无取消选项 */}
+      {showForceUpdateModal && (
+        <div
+          style={{
+            position: 'fixed',
+            top: 0,
+            left: 0,
+            right: 0,
+            bottom: 0,
+            backgroundColor: 'rgba(0,0,0,0.6)',
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: 'center',
+            zIndex: 10001
+          }}
+        >
+          <div
+            style={{
+              backgroundColor: '#fff',
+              padding: '28px 32px',
+              borderRadius: '8px',
+              minWidth: '360px',
+              maxWidth: '420px',
+              boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
+              textAlign: 'center'
+            }}
+          >
+            <div style={{ fontSize: '16px', color: '#333', marginBottom: '24px' }}>
+              新版本已下载,请重启以完成更新。
+            </div>
+            {updateInfo?.releaseNotes ? (
+              <div
+                style={{
+                  textAlign: 'left',
+                  fontSize: '14px',
+                  color: '#555',
+                  marginBottom: '20px',
+                  padding: '12px',
+                  backgroundColor: '#f5f5f5',
+                  borderRadius: '6px',
+                  maxHeight: '160px',
+                  overflowY: 'auto',
+                  whiteSpace: 'pre-wrap',
+                  wordBreak: 'break-word'
+                }}
+              >
+                {updateInfo.releaseNotes}
+              </div>
+            ) : null}
+            <button
+              onClick={() => window.electron?.ipcRenderer?.send('quit-and-install')}
+              style={{
+                width: '100%',
+                padding: '12px 24px',
+                backgroundColor: '#1aad19',
+                color: '#fff',
+                border: 'none',
+                borderRadius: '6px',
+                cursor: 'pointer',
+                fontSize: '15px',
+                fontWeight: '500'
+              }}
+            >
+              立即重启并更新
+            </button>
+          </div>
+        </div>
+      )}
+
       {/* 历史搜索弹窗 - 简化版本,可以后续提取 */}
       {showChatSearchModal && (
         <div 

+ 33 - 15
src/renderer/src/components/AppMappingList.tsx

@@ -99,6 +99,10 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
   // 滚动容器引用
   const scrollContainerRef = useRef<HTMLDivElement>(null)
   const searchTimerRef = useRef<NodeJS.Timeout | null>(null)
+  // 请求版本号,避免慢请求覆盖新搜索结果
+  const loadIdRef = useRef(0)
+  const searchKeywordRef = useRef(searchKeyword)
+  searchKeywordRef.current = searchKeyword
 
   // 同步 skipRef
   useEffect(() => {
@@ -137,6 +141,8 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
   const loadMappings = useCallback(async (isRefresh: boolean = false) => {
     if (isLoading || (isLoadingMore && !isRefresh)) return
 
+    const currentLoadId = (loadIdRef.current += 1)
+
     try {
       if (isRefresh) {
         setIsRefreshing(true)
@@ -150,28 +156,30 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
       setError(null)
 
       const currentSkip = isRefresh ? 0 : skipRef.current
-      
+
+      const keyword = searchKeywordRef.current.trim() || undefined
       const result = await api.getLaunchpadApps(token, {
         skip: currentSkip,
         limit: pageSize,
-        app_name: searchKeyword || undefined
+        app_name: keyword
       })
 
+      // 仅应用最新请求的结果,避免慢请求覆盖搜索结果
+      if (currentLoadId !== loadIdRef.current) return
+
       if (isRefresh) {
         setMappings(result.items)
         setTotal(result.total)
         setHasMore(result.items.length < result.total)
       } else {
-        // 去重(避免重复数据)
         setMappings(prev => {
           const existingIds = new Set(prev.map(m => `${m.app_id}_${m.mapped_key}`))
           const uniqueNewMappings = result.items.filter(
             m => !existingIds.has(`${m.app_id}_${m.mapped_key}`)
           )
-          
-          setHasMore(prev.length + uniqueNewMappings.length < result.total)
-          
-          return [...prev, ...uniqueNewMappings]
+          const nextList = [...prev, ...uniqueNewMappings]
+          setHasMore(nextList.length < result.total)
+          return nextList
         })
       }
 
@@ -181,14 +189,17 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
         setIsLoadingMore(false)
       }
     } catch (err: any) {
+      if (currentLoadId !== loadIdRef.current) return
       logger.error('AppMappingList: Failed to load mappings', err)
       setError(err.message || '加载应用列表失败')
       setIsRefreshing(false)
       setIsLoadingMore(false)
     } finally {
-      setIsLoading(false)
+      if (currentLoadId === loadIdRef.current) {
+        setIsLoading(false)
+      }
     }
-  }, [token, searchKeyword, pageSize])
+  }, [token, pageSize])
 
   // 初始化加载
   useEffect(() => {
@@ -242,10 +253,17 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
     }, 100)
   }, [isRefreshing, isLoading, isLoadingMore, hasMore, loadMappings])
 
+  // 前端按关键词过滤展示列表(兜底:后端未过滤时也能正确显示)
+  const displayedMappings = useMemo(() => {
+    const kw = searchKeyword.trim().toLowerCase()
+    if (!kw) return mappings
+    return mappings.filter(m => (m.app_name || '').toLowerCase().includes(kw))
+  }, [mappings, searchKeyword])
+
   const categorizedApps = useMemo(() => {
     const groupMap = new Map<number | null, { categoryId: number | null; categoryName: string; apps: AppMapping[] }>()
 
-    for (const app of mappings) {
+    for (const app of displayedMappings) {
       const key = app.category_id
       if (!groupMap.has(key)) {
         groupMap.set(key, {
@@ -265,7 +283,7 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
     })
 
     return groups
-  }, [mappings])
+  }, [displayedMappings])
 
   // 处理 SSO 登录
   const handleSsoLogin = async (mapping: AppMapping) => {
@@ -548,7 +566,7 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
           ))
         ) : (
           <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '20px' }}>
-            {mappings.map((mapping) => (
+            {displayedMappings.map((mapping) => (
               <div
                 key={`${mapping.app_id}_${mapping.mapped_key}`}
                 className={`browser-card ${!mapping.is_active || loginLoading[mapping.app_id] ? 'disabled' : ''}`}
@@ -582,17 +600,17 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
         )}
 
         {/* 没有更多数据提示 */}
-        {!hasMore && mappings.length > 0 && (
+        {!hasMore && displayedMappings.length > 0 && (
           <div style={{ textAlign: 'center', padding: '20px', color: '#999' }}>
             没有更多了
           </div>
         )}
 
         {/* 空状态 */}
-        {!isLoading && mappings.length === 0 && !error && (
+        {!isLoading && displayedMappings.length === 0 && !error && (
           <div style={{ textAlign: 'center', padding: '60px 20px', color: '#999' }}>
             <div style={{ fontSize: '64px', marginBottom: '16px', opacity: 0.5 }}>📱</div>
-            <div style={{ fontSize: '16px' }}>暂无应用</div>
+            <div style={{ fontSize: '16px' }}>{searchKeyword.trim() ? '未找到匹配的应用' : '暂无应用'}</div>
           </div>
         )}
       </div>

+ 72 - 1
src/renderer/src/components/Modals/SettingsModal.tsx

@@ -1,11 +1,17 @@
 import React from 'react'
 
+type UpdateStatus = 'idle' | 'checking' | 'update-available' | 'update-downloaded' | 'latest' | 'error'
+
 interface SettingsModalProps {
   isOpen: boolean
   onClose: () => void
   currentUserId: number | null
   currentUserName: string
   onLogout: () => void
+  updateStatus?: UpdateStatus
+  updateError?: string
+  updateDownloadProgress?: number | null
+  onCheckForUpdates?: () => void
 }
 
 export const SettingsModal: React.FC<SettingsModalProps> = ({
@@ -13,10 +19,29 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
   onClose,
   currentUserId,
   currentUserName,
-  onLogout
+  onLogout,
+  updateStatus = 'idle',
+  updateError = '',
+  updateDownloadProgress = null,
+  onCheckForUpdates
 }) => {
   if (!isOpen) return null
 
+  const hasUpdateAPI = typeof window !== 'undefined' && window.electron?.ipcRenderer
+
+  const updateStatusText =
+    updateStatus === 'checking'
+      ? '检查中…'
+      : updateStatus === 'update-available'
+        ? '发现新版本,正在下载…'
+        : updateStatus === 'update-downloaded'
+          ? '新版本已下载,请重启以更新'
+          : updateStatus === 'latest'
+            ? '当前已是最新版本'
+            : updateStatus === 'error'
+              ? updateError || '检查失败'
+              : ''
+
   return (
     <div 
       style={{
@@ -54,6 +79,52 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
             <div style={{ color: '#999', fontSize: '12px', marginBottom: '5px' }}>用户名</div>
             <div style={{ color: '#333', fontSize: '14px' }}>{currentUserName || '未知'}</div>
           </div>
+
+          {hasUpdateAPI && onCheckForUpdates && (
+            <div style={{ marginBottom: '20px', paddingBottom: '15px', borderBottom: '1px solid #f0f0f0' }}>
+              <div style={{ color: '#999', fontSize: '12px', marginBottom: '8px' }}>检查更新</div>
+              <button
+                onClick={onCheckForUpdates}
+                disabled={updateStatus === 'checking' || updateStatus === 'update-available' || updateStatus === 'update-downloaded'}
+                style={{
+                  width: '100%',
+                  padding: '10px 20px',
+                  backgroundColor: updateStatus === 'checking' || updateStatus === 'update-available' ? '#ccc' : '#1aad19',
+                  color: '#fff',
+                  border: 'none',
+                  borderRadius: '4px',
+                  cursor: updateStatus === 'checking' || updateStatus === 'update-available' || updateStatus === 'update-downloaded' ? 'not-allowed' : 'pointer',
+                  fontSize: '14px',
+                  fontWeight: '500'
+                }}
+              >
+                {updateStatus === 'checking' || updateStatus === 'update-available' ? '检查中…' : '检查更新'}
+              </button>
+              {updateStatus === 'update-available' && updateDownloadProgress != null && (
+                <div style={{ marginTop: '10px' }}>
+                  <div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
+                    正在下载… {Math.round(updateDownloadProgress)}%
+                  </div>
+                  <div style={{ height: 6, backgroundColor: '#eee', borderRadius: 3, overflow: 'hidden' }}>
+                    <div
+                      style={{
+                        height: '100%',
+                        width: `${Math.min(100, Math.round(updateDownloadProgress))}%`,
+                        backgroundColor: '#1aad19',
+                        transition: 'width 0.2s ease'
+                      }}
+                    />
+                  </div>
+                </div>
+              )}
+              {updateStatusText && (
+                <div style={{ marginTop: '8px', fontSize: '13px', color: updateStatus === 'error' ? '#ff3b30' : '#666' }}>
+                  {updateStatusText}
+                </div>
+              )}
+            </div>
+          )}
+
           <button 
             onClick={() => {
               onLogout()