feat(electron): add persistent auth, auto-updater, and NAS copy script

- Integrate electron-store for persistent PocketBase auth backup
  across localStorage clears and app reinstalls
- Switch build target from portable to nsis to generate latest.yml
  for electron-updater generic provider
- Add user confirmation dialogs before download and before install
- Add post-build script to copy .exe/.yml/.nupkg to NAS share and
  local electron-update/ directory for nginx volume mount
- Mount ./electron-update into frontend nginx containers via
  docker-compose for automatic update file serving

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wjl
2026-04-21 14:38:48 +08:00
parent d3ef22f06e
commit 6b3cd288b1
11 changed files with 318 additions and 16 deletions
+50 -8
View File
@@ -10,19 +10,30 @@ Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队
```bash
# 构建前端
# 注意:npm run build 默认加载 .env.dev 的变量。Docker 构建时通过 ARG 注入空 VITE_PB_URL,由 nginx 代理处理
# 如需本地测试 UAT 构建设置,使用:cd frontend && npm run build -- --mode uat
cd frontend && npm run build
# 本地开发(一般不用,用 Docker 部署代替)
# 本地 vite dev server(一般不用,用 Docker 部署代替)
cd frontend && npm run dev
# 部署脚本(根目录)
./deploy-backend.sh # 部署 PocketBase 后端
./deploy-backend.sh # 部署 PocketBase + LiveKit + Voice Token 后端
./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033)
./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712)
./stop-all.sh # 停止所有服务
# Electron 桌面端
cd electron && npm install
cd electron && npm run start # 启动桌面端(默认连接 Dev
cd electron && npm run start:dev # 同上,显式指定 dev 环境
cd electron && npm run start:uat # 连接 UAT 环境
cd electron && npm run build # 打包 Windows 可执行文件
# 查看日志
docker logs -f gamegroup-pb # 后端
docker logs -f gamegroup-pb # 后端
docker logs -f gamegroup-livekit # LiveKit 语音服务
docker logs -f gamegroup-voice-token # Voice Token 服务
docker logs -f gamegroup-frontend-dev # Dev
docker logs -f gamegroup-frontend-uat # UAT
```
@@ -36,6 +47,8 @@ docker logs -f gamegroup-frontend-uat # UAT
- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包)localStorage 持久化认证
- **实时通信**: PocketBase realtime subscriptions
- **样式**: 自定义 CSS 变量 (`--gg-*` 前缀, `design.css`) + Tailwind + Element Plus,绿色主题
- **语音通话**: LiveKit server v1.10 + `livekit-client` + 独立 Express token 服务
- **桌面端**: Electron 35 + `electron-store` + `electron-builder`
## 环境与端口
@@ -43,9 +56,13 @@ docker logs -f gamegroup-frontend-uat # UAT
|------|-----|-----|
| 前端 (nginx) | 7033 | 7034 |
| PocketBase | 8090 | 8712 |
| LiveKit | 7880/7881/7882 | 7880/7881/7882 |
| Voice Token | 7882 | 7882 |
Docker Compose 文件:`docker-compose.backend.yml``docker-compose.dev.yml``docker-compose.uat.yml`,共享 `gamegroup-net` 网络。
前端 `.env` 文件:`frontend/.env.dev`VITE_PB_URL=8711, PORT=7033)和 `frontend/.env.uat`VITE_PB_URL=8711, PORT=7034)。注意 Dev/UAT 构建时 VITE_PB_URL 都指向 8711,因为 Docker 构建时会将其覆盖为空,由 nginx 反向代理处理。
## 架构
### 前端核心流程
@@ -59,9 +76,10 @@ pocketbase.ts (PB 客户端初始化)
```
- **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb``getCurrentUser()``isAuthenticated()``logout()`
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`, `voice.ts`
- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
- **`composables/useVoiceRoom.ts`** — LiveKit 语音房间封装,处理连接/断开/麦克风/扬声器控制
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + 状态映射常量(如 `UserStatusMap``TeamStatusMap`
### 认证流程
@@ -72,17 +90,41 @@ pocketbase.ts (PB 客户端初始化)
### 路由结构
Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。
Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), VoiceRoom (`/group/:groupId/voice/:sessionId`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。
### Vite 代理 vs Nginx
开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。
开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。Voice Token 服务通过 `/voice-api/` 路径由 nginx 代理到 7882 端口。
前端 nginx 配置有两份:`nginx.conf`dev,代理到 8090)和 `nginx.uat.conf`(代理到 8712),Docker 构建时通过 `NGINX_CONF` build arg 选择。静态资源缓存一年,HTML 不缓存。
### 语音通话架构
组队会话 (`team_sessions`) 支持语音房间。语音流程:
1. 用户点击语音按钮 → 路由跳转到 `VoiceRoom.vue`
2. `useVoiceRoom.connect(sessionId)` 调用 `api/voice.ts``fetchVoiceToken`
3. `fetchVoiceToken` 向后端 `/voice-api/voice-token/:sessionId` 请求 token
4. Voice Token 服务验证用户 PB token → 查询 `team_sessions` 确认成员身份 → 签发 LiveKit JWT
5. 前端用 token 连接 LiveKit server (`ws://192.168.1.14:7880`)
HTTP 环境下 `navigator.mediaDevices` 受限,已在 `useVoiceRoom.ts` 中给出明确的 Chrome flags 引导错误提示。
### Electron 桌面端
- `electron/main.js` — 主进程,加载远程 URL(dev/uat 可切换),窗口大小 1280x800
- `electron/preload.js` — 预加载脚本(当前为空壳,保留扩展点)
- `electron/package.json` — 独立包,依赖 `electron-store`(用于本地配置持久化)
- 构建产物为 Windows portable 可执行文件
### 实时订阅管理
所有 PocketBase 实时订阅统一通过 `useRealtime.ts` composable 管理。组件使用时在 `onMounted` 中调用订阅方法,`onUnmounted` 时自动调用 `unsubscribeAll` 清理。不要直接在组件中创建孤立的 `pb.collection().subscribe()` 而不做清理。
### 数据模型(PocketBase Collections
- **users** — 认证集合。`username` 是系统字段(不可改,仅 `[a-z0-9_-]`),中文昵称存 `name` 字段。状态:idle/working/in_team/away
- **groups** — owner + members 关系,支持审核加入(requireApproval
- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved
- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved。含 `voiceRoom``voiceActive` 字段
- **invitations** — 组队邀请,pending/accepted/rejected
- **games** — 游戏库,归属 group,含平台、标签、封面
- **game_comments** / **game_favorites** — 评论和收藏
@@ -90,7 +132,7 @@ Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`),
- **polls** / **poll_options** / **poll_votes** — 投票(选项投票/点名),含匿名、截止时间
- **bets** / **bet_options** / **bet_entries** — 积分竞猜,含下注范围和结算
- **point_logs** — 积分流水(vote/team/memory/bet 行为)
- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group
- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group。nginx 配置 `client_max_body_size 500m` 支持大文件上传
- **ledgers** — 群组账本(收入/支出),按游戏/聚餐/设备/交通分类
- **assets** — 群组资产(游戏账号/主机/设备/配件),含当前持有者
- **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差)
+2
View File
@@ -6,6 +6,8 @@ services:
container_name: gamegroup-frontend-dev
ports:
- "7033:80"
volumes:
- ./electron-update:/usr/share/nginx/html/electron-update
environment:
- NODE_ENV=production
restart: unless-stopped
+2
View File
@@ -59,6 +59,8 @@ services:
container_name: gamegroup-frontend-uat
ports:
- "7034:80"
volumes:
- ./electron-update:/usr/share/nginx/html/electron-update
environment:
- NODE_ENV=production
restart: unless-stopped
View File
+125 -2
View File
@@ -1,5 +1,22 @@
const { app, BrowserWindow, session } = require('electron')
const { app, BrowserWindow, session, dialog, ipcMain } = require('electron')
const { autoUpdater } = require('electron-updater')
const path = require('path')
const Store = require('electron-store')
const store = new Store()
// 同步 IPC:供 preload 读写持久化数据
ipcMain.on('store-get-sync', (event, key) => {
event.returnValue = store.get(key)
})
ipcMain.on('store-set-sync', (event, key, value) => {
store.set(key, value)
event.returnValue = true
})
ipcMain.on('store-delete-sync', (event, key) => {
store.delete(key)
event.returnValue = true
})
const ENV_URLS = {
dev: 'http://192.168.1.14:7033',
@@ -15,11 +32,19 @@ function getWindowUrl() {
return ENV_URLS.dev
}
function getEnvName() {
const url = getWindowUrl()
if (url.includes('7034')) return 'uat'
return 'dev'
}
// 在 Chromium 启动前将 HTTP 内网地址标记为安全源,
// 否则 navigator.mediaDevices 在 HTTP 非 localhost 下会被置空
const insecureOrigins = Object.values(ENV_URLS).join(',')
app.commandLine.appendSwitch('unsafely-treat-insecure-origin-as-secure', insecureOrigins)
let mainWindow = null
function createWindow() {
const win = new BrowserWindow({
width: 1280,
@@ -36,6 +61,8 @@ function createWindow() {
},
})
mainWindow = win
const url = getWindowUrl()
win.loadURL(url)
@@ -44,6 +71,101 @@ function createWindow() {
event.preventDefault()
win.setTitle(`Game Group - ${title}`)
})
win.on('closed', () => {
mainWindow = null
})
return win
}
// 自动更新配置
function setupAutoUpdater(win) {
const env = getEnvName()
const feedUrl = `${ENV_URLS[env]}/electron-update/`
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl })
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
autoUpdater.on('checking-for-update', () => {
console.log('[updater] Checking for update at', feedUrl)
})
autoUpdater.on('update-available', (info) => {
console.log('[updater] Update available:', info.version)
if (win && !win.isDestroyed()) {
win.webContents.send('update-available', info)
dialog.showMessageBox(win, {
type: 'info',
title: '发现新版本',
message: `发现新版本 ${info.version},是否立即下载更新?`,
buttons: ['立即下载', '稍后再说'],
defaultId: 0,
cancelId: 1,
}).then(({ response }) => {
if (response === 0) {
autoUpdater.downloadUpdate().catch((err) => {
console.error('[updater] Download failed:', err.message)
})
}
})
}
})
autoUpdater.on('update-not-available', () => {
console.log('[updater] No update available')
})
autoUpdater.on('error', (err) => {
console.error('[updater] Error:', err.message)
if (win && !win.isDestroyed()) {
win.webContents.send('update-error', err.message)
}
})
autoUpdater.on('download-progress', (progress) => {
console.log(`[updater] Download progress: ${progress.percent.toFixed(1)}%`)
if (win && !win.isDestroyed()) {
win.webContents.send('download-progress', progress)
}
})
autoUpdater.on('update-downloaded', (info) => {
console.log('[updater] Update downloaded:', info.version)
if (win && !win.isDestroyed()) {
win.webContents.send('update-downloaded', info)
dialog.showMessageBox(win, {
type: 'info',
title: '更新就绪',
message: `新版本 ${info.version} 已下载完成,是否立即重启安装?`,
buttons: ['立即重启', '稍后'],
defaultId: 0,
}).then(({ response }) => {
if (response === 0) {
autoUpdater.quitAndInstall()
}
})
}
})
// 手动触发检查更新(供前端调用)
ipcMain.on('check-for-updates', () => {
autoUpdater.checkForUpdates().catch((err) => {
console.error('[updater] Manual check failed:', err.message)
})
})
ipcMain.on('quit-and-install', () => {
autoUpdater.quitAndInstall()
})
// 启动后延迟 5 秒检查更新(等窗口稳定后再检查)
setTimeout(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error('[updater] Initial check failed:', err.message)
})
}, 5000)
}
app.whenReady().then(() => {
@@ -64,7 +186,8 @@ app.whenReady().then(() => {
return false
})
createWindow()
const win = createWindow()
setupAutoUpdater(win)
})
app.on('window-all-closed', () => {
+20 -4
View File
@@ -7,11 +7,15 @@
"start": "electron .",
"start:dev": "electron . --env=dev",
"start:uat": "electron . --env=uat",
"build": "electron-builder --win",
"build:portable": "electron-builder --win portable"
"build": "electron-builder --win && node scripts/copy-to-nas.js",
"build:portable": "electron-builder --win portable && node scripts/copy-to-nas.js",
"copy-to-nas": "node scripts/copy-to-nas.js"
},
"dependencies": {
"electron-store": "^8.2"
"electron-store": "^8.2",
"electron-updater": "^6.8.3",
"png-to-ico": "^3.0.1",
"sharp": "^0.34.5"
},
"devDependencies": {
"electron": "^35.0",
@@ -26,12 +30,24 @@
"win": {
"target": [
{
"target": "portable",
"target": "nsis",
"arch": ["x64"]
}
],
"icon": "build/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "GameGroup"
},
"publish": {
"provider": "generic",
"url": "http://nas.wjl-work.top:7034/electron-update/",
"channel": "latest"
},
"files": [
"main.js",
"preload.js",
+15 -2
View File
@@ -1,6 +1,19 @@
// preload.js - 目前为空,预留用于未来需要暴露给渲染进程的 API
const { contextBridge } = require('electron')
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform,
// 自动更新相关 IPC
onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (_event, info) => callback(info)),
onUpdateError: (callback) => ipcRenderer.on('update-error', (_event, message) => callback(message)),
onDownloadProgress: (callback) => ipcRenderer.on('download-progress', (_event, progress) => callback(progress)),
onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (_event, info) => callback(info)),
checkForUpdates: () => ipcRenderer.send('check-for-updates'),
quitAndInstall: () => ipcRenderer.send('quit-and-install'),
// 持久化存储(用于记住登录状态)
storeGet: (key) => ipcRenderer.sendSync('store-get-sync', key),
storeSet: (key, value) => ipcRenderer.sendSync('store-set-sync', key, value),
storeDelete: (key) => ipcRenderer.sendSync('store-delete-sync', key),
})
+63
View File
@@ -0,0 +1,63 @@
const fs = require('fs')
const path = require('path')
const srcDir = path.join(__dirname, '..', 'dist')
// 项目根目录下的 electron-update,用于 docker volume 挂载给 nginx
const localUpdateDir = path.join(__dirname, '..', '..', 'electron-update')
// NAS 共享路径
const nasDir = '\\\\JIULUGNAS\\personal_folder\\CodeSpace\\GameGroup2'
function ensureDir(dir) {
try {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
return true
} catch (err) {
console.error(`[copy-to-nas] failed to create dir ${dir}:`, err.message)
return false
}
}
function copyFilesTo(destDir, label) {
if (!fs.existsSync(srcDir)) {
console.log(`[copy-to-nas] dist not found, skipping ${label}`)
return
}
const files = fs.readdirSync(srcDir).filter((f) => {
// 只复制更新相关文件:安装包、yml、blockmap、nupkg
const ext = path.extname(f).toLowerCase()
return (
ext === '.exe' ||
ext === '.yml' ||
ext === '.blockmap' ||
ext === '.nupkg' ||
ext === '.yaml'
)
})
if (files.length === 0) {
console.log(`[copy-to-nas] no update files found in dist, skipping ${label}`)
return
}
if (!ensureDir(destDir)) return
for (const file of files) {
const src = path.join(srcDir, file)
const dest = path.join(destDir, file)
try {
fs.copyFileSync(src, dest)
console.log(`[copy-to-nas] copied ${file} -> ${destDir}`)
} catch (err) {
console.error(`[copy-to-nas] failed to copy ${file} to ${label}:`, err.message)
}
}
}
// 复制到本地目录(供 docker volume 挂载)
copyFilesTo(localUpdateDir, 'local')
// 复制到 NAS
copyFilesTo(nasDir, 'NAS')
+8
View File
@@ -39,6 +39,14 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Electron 自动更新文件托管
# 部署时将 electron/dist/latest.yml 和 electron/dist/*.exe 放到此目录
location /electron-update/ {
alias /usr/share/nginx/html/electron-update/;
autoindex on;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# API 代理到局域网 PocketBase
location /api/ {
client_max_body_size 500m;
+8
View File
@@ -39,6 +39,14 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Electron 自动更新文件托管
# 部署时将 electron/dist/latest.yml 和 electron/dist/*.exe 放到此目录
location /electron-update/ {
alias /usr/share/nginx/html/electron-update/;
autoindex on;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# API 代理到局域网 PocketBase
location /api/ {
proxy_pass http://192.168.1.14:8712;
+25
View File
@@ -7,6 +7,27 @@ export const pb = new PocketBase(pbUrl)
// SDK v0.21+ 自动使用 localStorage 持久化,无需手动 cookie 操作
// Electron 环境下使用 electron-store 做更持久的备份
const eAPI = (window as any).electronAPI
if (eAPI?.storeGet) {
// 若 localStorage 无有效认证,尝试从 electron-store 恢复
if (!pb.authStore.isValid) {
const saved = eAPI.storeGet('pb_auth')
if (saved?.token && saved?.model) {
pb.authStore.save(saved.token, saved.model)
}
}
// 认证状态变化时同步到 electron-store
pb.authStore.onChange((token: string, model: any) => {
if (token && model) {
eAPI.storeSet('pb_auth', { token, model })
} else {
eAPI.storeDelete('pb_auth')
}
})
}
// 获取当前用户
export function getCurrentUser() {
return pb.authStore.model
@@ -20,6 +41,10 @@ export function isAuthenticated(): boolean {
// 登出
export function logout() {
pb.authStore.clear()
const eAPI = (window as any).electronAPI
if (eAPI?.storeDelete) {
eAPI.storeDelete('pb_auth')
}
window.location.href = '/login'
}