feat:项目打包成Electron

This commit is contained in:
2026-06-14 18:10:58 +08:00
parent 7a0443ebcc
commit 8970b63800
20 changed files with 3895 additions and 11 deletions

4
.gitignore vendored
View File

@@ -2,6 +2,10 @@
frontend/node_modules/
frontend/dist/
# ---- 桌面端 ----
desktop/node_modules/
desktop/dist/
# ---- 后端 ----
backend/bin/
backend/server

4
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# CamTalk Desktop 依赖
node_modules/
dist/
*.log

4
desktop/.npmrc Normal file
View File

@@ -0,0 +1,4 @@
# 淘宝镜像源
registry=https://registry.npmmirror.com
# Electron 二进制文件镜像
electron_mirror=https://npmmirror.com/mirrors/electron/

84
desktop/README.md Normal file
View File

@@ -0,0 +1,84 @@
# CamTalk Desktop — 桌面客户端
基于 Electron 的 CamTalk 桌面应用,解决 HTTP 环境下摄像头和麦克风无法访问的问题。
## 原理
Electron 以 `file://` 协议加载页面Chromium 将其视为安全上下文,`getUserMedia` 可直接工作。
## 快速开始
### 1. 安装依赖
```bash
cd desktop
npm install
```
### 2. 启动前端开发服务器
```bash
cd ../frontend
npm run dev
```
### 3. 启动 Electron开发模式
```bash
cd desktop
npm run dev
```
Electron 会连接到 `http://localhost:5173`Vite 开发服务器),支持热更新。
### 4. 启动后端
```bash
cd ../backend
go run ./cmd/server
```
## 生产构建
```bash
# 1. 构建前端
cd ../frontend
npm run build
# 2. 构建 Electron
cd ../desktop
npm run build
# 3. 预览生产版本
npm run preview
```
## 配置后端地址
默认连接 `ws://localhost:8080/ws`。如需修改:
- **方式 1**:在 Electron 应用的设置面板中修改(功能开发中)
- **方式 2**:直接编辑配置文件
- macOS: `~/Library/Application Support/camtalk-desktop/camtalk-config.json`
- Linux: `~/.config/camtalk-desktop/camtalk-config.json`
## 项目结构
```
desktop/
├── main/
│ ├── index.ts # Main Process 入口
│ ├── window.ts # BrowserWindow 管理
│ ├── permissions.ts # 权限自动授予
│ └── config.ts # 配置管理
├── preload/
│ └── index.ts # contextBridge 安全桥接
├── assets/ # 应用图标等资源
└── package.json
```
## 注意事项
- 首次运行可能需要授予摄像头和麦克风权限(系统级别)
- 如果连接远程后端,确保网络可访问
- `webSecurity: false` 是为了允许 HTTP 资源加载,仅适用于可信环境

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened Runtime: 允许使用 JITElectron/V8 需要) -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- 允许加载不同签名的 framework开发模式下必需 -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- 摄像头 -->
<key>com.apple.security.device.camera</key>
<true/>
<!-- 麦克风 -->
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- USB 设备 -->
<key>com.apple.security.device.usb</key>
<true/>
<!-- 网络 -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,30 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'dist/main',
rollupOptions: {
input: {
index: resolve(__dirname, 'main/index.ts'),
},
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'dist/preload',
rollupOptions: {
input: {
index: resolve(__dirname, 'preload/index.ts'),
},
},
},
},
// 前端是独立项目,不使用 electron-vite 的 renderer 配置
// 开发时连接外部 Vite dev server (http://localhost:5173)
// 生产时加载本地文件 (../frontend/dist/index.html)
})

49
desktop/main/config.ts Normal file
View File

@@ -0,0 +1,49 @@
// ============================================================
// 配置管理 — 后端地址持久化
// 职责:存储和读取用户配置的后端 WebSocket 地址
// ============================================================
import Store from 'electron-store'
// 配置结构
interface AppConfig {
backendUrl: string
windowBounds?: {
width: number
height: number
x?: number
y?: number
}
}
// 默认配置
const DEFAULT_BACKEND_URL = 'ws://localhost:8080/ws'
// 初始化配置存储
const store = new Store<AppConfig>({
name: 'camtalk-config',
defaults: {
backendUrl: DEFAULT_BACKEND_URL,
},
})
/**
* 获取后端 WebSocket 地址
*/
export function getBackendUrl(): string {
return store.get('backendUrl', DEFAULT_BACKEND_URL)
}
/**
* 设置后端 WebSocket 地址
*/
export function setBackendUrl(url: string): void {
store.set('backendUrl', url)
}
/**
* 重置为默认配置
*/
export function resetConfig(): void {
store.set('backendUrl', DEFAULT_BACKEND_URL)
}

248
desktop/main/index.ts Normal file
View File

@@ -0,0 +1,248 @@
// ============================================================
// CamTalk Desktop — Main Process 入口
// 职责应用生命周期管理、窗口创建、权限授予、IPC 处理
// ============================================================
import { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } from 'electron'
import { createMainWindow } from './window'
import { setupPermissions } from './permissions'
import { getBackendUrl, setBackendUrl } from './config'
// 安全的 console.log防止管道断裂时 EPIPE 崩溃
function safeLog(...args: unknown[]): void {
try { console.log(...args) } catch { /* EPIPE 等管道错误,忽略 */ }
}
let mainWindow: BrowserWindow | null = null
// ---- IPC 处理 ----
ipcMain.on('get-backend-url', (event) => {
event.returnValue = getBackendUrl()
})
ipcMain.on('set-backend-url', (_, url: string) => {
setBackendUrl(url)
safeLog('[IPC] 后端地址已更新:', url)
})
ipcMain.on('get-version', (event) => {
event.returnValue = app.getVersion()
})
// IPC: 渲染进程请求摄像头权限
// macOS 26 下 askForMediaAccess 对 ad-hoc 签名不弹窗,
// 因此先尝试 askForMediaAccess若失败则仍放行让 getUserMedia 自然触发系统弹窗
ipcMain.handle('request-camera-access', async () => {
if (process.platform !== 'darwin') return true
const status = systemPreferences.getMediaAccessStatus('camera')
safeLog(`[Media] 摄像头权限状态: ${status}`)
if (status === 'granted') return true
if (status === 'not-determined') {
// 先尝试 askForMediaAccess某些 macOS 版本会弹窗)
const granted = await systemPreferences.askForMediaAccess('camera')
safeLog(`[Media] askForMediaAccess('camera') 结果: ${granted}`)
if (granted) return true
// macOS 26 下 askForMediaAccess 可能不弹窗直接返回 false
// 仍然放行,让渲染进程的 getUserMedia 尝试自然触发系统弹窗
safeLog('[Media] askForMediaAccess 未授权,交由 getUserMedia 自然触发')
}
return true // 始终放行
})
// IPC: 渲染进程请求麦克风权限
ipcMain.handle('request-mic-access', async () => {
if (process.platform !== 'darwin') return true
const status = systemPreferences.getMediaAccessStatus('microphone')
safeLog(`[Media] 麦克风权限状态: ${status}`)
if (status === 'granted') return true
if (status === 'not-determined') {
const granted = await systemPreferences.askForMediaAccess('microphone')
safeLog(`[Media] 麦克风权限: ${granted ? '已授予' : '已拒绝'}`)
return granted
}
return false
})
/**
* 请求 macOS 摄像头/麦克风权限
*
* 策略(依次尝试):
* 1. systemPreferences.askForMediaAccess — 正式签名时弹系统 TCC 弹窗
* 2. renderer 执行 getUserMedia — 兜底触发 macOS TCC 弹窗
* 3. 若仍未授权 → 应用内弹窗引导用户一键打开系统设置,并自动轮询权限变化
*/
async function requestMediaAccess(win: BrowserWindow): Promise<void> {
if (process.platform !== 'darwin') return
// ---------- 摄像头 ----------
let camStatus = systemPreferences.getMediaAccessStatus('camera')
safeLog(`[Media] 摄像头初始状态: ${camStatus}`)
if (camStatus !== 'granted') {
if (camStatus === 'not-determined') {
const asked = await systemPreferences.askForMediaAccess('camera')
safeLog(`[Media] askForMediaAccess('camera') => ${asked}`)
}
camStatus = systemPreferences.getMediaAccessStatus('camera')
if (camStatus !== 'granted') {
safeLog('[Media] 尝试 getUserMedia 兜底触发 TCC...')
try {
await win.webContents.executeJavaScript(`
navigator.mediaDevices.getUserMedia({ video: true })
.then(s => { s.getTracks().forEach(t => t.stop()); true })
.catch(() => false)
`, true)
} catch (e) {
safeLog('[Media] getUserMedia 异常:', e)
}
await new Promise(r => setTimeout(r, 2000))
}
camStatus = systemPreferences.getMediaAccessStatus('camera')
safeLog(`[Media] 摄像头最终状态: ${camStatus}`)
if (camStatus !== 'granted') {
await showPermissionDialog(win, 'camera')
}
}
// ---------- 麦克风 ----------
let micStatus = systemPreferences.getMediaAccessStatus('microphone')
safeLog(`[Media] 麦克风初始状态: ${micStatus}`)
if (micStatus !== 'granted') {
if (micStatus === 'not-determined') {
const asked = await systemPreferences.askForMediaAccess('microphone')
safeLog(`[Media] askForMediaAccess('microphone') => ${asked}`)
}
micStatus = systemPreferences.getMediaAccessStatus('microphone')
if (micStatus !== 'granted') {
try {
await win.webContents.executeJavaScript(`
navigator.mediaDevices.getUserMedia({ audio: true })
.then(s => { s.getTracks().forEach(t => t.stop()); true })
.catch(() => false)
`, true)
} catch (e) {
safeLog('[Media] getUserMedia 异常:', e)
}
await new Promise(r => setTimeout(r, 2000))
}
micStatus = systemPreferences.getMediaAccessStatus('microphone')
safeLog(`[Media] 麦克风最终状态: ${micStatus}`)
if (micStatus !== 'granted') {
await showPermissionDialog(win, 'microphone')
}
}
}
/**
* 应用内权限引导弹窗
* - 一键打开系统设置对应的隐私页面
* - 后台自动轮询权限状态,授权后立刻通知 renderer 刷新
*/
async function showPermissionDialog(
win: BrowserWindow,
type: 'camera' | 'microphone',
): Promise<void> {
const label = type === 'camera' ? '摄像头' : '麦克风'
const settingsURL =
type === 'camera'
? 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'
: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'
safeLog(`[Media] 弹出${label}权限引导弹窗`)
const { response } = await dialog.showMessageBox(win, {
type: 'info',
title: `需要${label}权限`,
message: `CamTalk 需要${label}权限才能正常使用。`,
detail:
`请点击下方按钮打开系统设置在列表中找到「Electron」并开启${label}权限。\n\n` +
`授权后应用会自动检测并立即启用。`,
buttons: [`打开${label}设置`, '暂时跳过'],
defaultId: 0,
cancelId: 1,
})
if (response === 0) {
shell.openExternal(settingsURL)
// 轮询等待用户授权(最多 120 秒)
safeLog(`[Media] 开始轮询${label}权限状态...`)
const granted = await pollPermission(type, 120_000)
if (granted) {
safeLog(`[Media] ✅ ${label}权限已授予,通知 renderer 刷新`)
win.webContents.send('permission-granted', type)
} else {
safeLog(`[Media] ⏰ ${label}权限轮询超时`)
}
}
}
/** 轮询权限状态直到 granted 或超时 */
function pollPermission(
type: 'camera' | 'microphone',
timeoutMs: number,
): Promise<boolean> {
return new Promise((resolve) => {
const start = Date.now()
const timer = setInterval(() => {
const status = systemPreferences.getMediaAccessStatus(type)
if (status === 'granted') {
clearInterval(timer)
resolve(true)
} else if (Date.now() - start > timeoutMs) {
clearInterval(timer)
resolve(false)
}
}, 1000)
})
}
app.whenReady().then(async () => {
setupPermissions()
mainWindow = createMainWindow()
// macOS: 窗口加载完成后主动请求摄像头和麦克风权限
mainWindow.webContents.once('did-finish-load', async () => {
safeLog('[Media] 页面加载完成,开始请求媒体权限...')
await requestMediaAccess(mainWindow!)
})
// 安全地转发渲染进程日志(防 EPIPE 崩溃)
mainWindow.webContents.on('console-message', (_event, _level, message) => {
if (!message.includes('Autofill') && !message.includes('Failed to fetch')) {
safeLog(`[Renderer] ${message}`)
}
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createMainWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('web-contents-created', (_, contents) => {
contents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://localhost:')) {
return { action: 'allow' }
}
shell.openExternal(url)
return { action: 'deny' }
})
})

View File

@@ -0,0 +1,42 @@
// ============================================================
// 权限管理 — 自动授予摄像头、麦克风权限
// 职责绕过浏览器的权限弹窗Electron 下直接授权
// ============================================================
import { session } from 'electron'
/**
* 设置权限自动授予
* Electron 默认不会弹出权限请求,需要在 Main Process 中显式处理
*/
export function setupPermissions(): void {
const allowedPermissions = [
'media', // 摄像头和麦克风
'mediaKeySystem', // 媒体键
'notifications', // 通知
'clipboard-sanitized-write', // 剪贴板写入
]
// 处理权限请求
session.defaultSession.setPermissionRequestHandler(
(_webContents, permission, callback, details) => {
console.log(`[Permissions] 收到权限请求: ${permission}`, details)
if (allowedPermissions.includes(permission)) {
console.log(`[Permissions] ✅ 已授予: ${permission}`)
callback(true)
} else {
console.warn(`[Permissions] ❌ 拒绝权限请求: ${permission}`)
callback(false)
}
}
)
// 权限检查:对允许的权限始终返回已授权
session.defaultSession.setPermissionCheckHandler(
(_webContents, permission) => {
return allowedPermissions.includes(permission)
}
)
console.log('[Permissions] 已设置权限自动授予:', allowedPermissions.join(', '))
}

45
desktop/main/window.ts Normal file
View File

@@ -0,0 +1,45 @@
// ============================================================
// BrowserWindow 创建与配置
// 职责:窗口属性、加载策略(开发/生产)、快捷键
// ============================================================
import { app, BrowserWindow, screen } from 'electron'
import { join } from 'path'
// 判断是否为开发模式
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
// 前端路径配置
const FRONTEND_DEV_URL = 'http://localhost:5173'
const FRONTEND_DIST_PATH = '../frontend/dist/index.html'
export function createMainWindow(): BrowserWindow {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
const win = new BrowserWindow({
width: Math.min(1400, width),
height: Math.min(900, height),
minWidth: 800,
minHeight: 600,
title: 'CamTalk',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
backgroundColor: '#1a1a2e',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
// 允许加载 http 资源(连接远程后端时需要)
webSecurity: false,
},
})
// 加载前端内容
if (isDev) {
win.loadURL(FRONTEND_DEV_URL)
win.webContents.openDevTools({ mode: 'detach' })
} else {
win.loadFile(join(__dirname, FRONTEND_DIST_PATH))
}
return win
}

2722
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
desktop/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "camtalk-desktop",
"version": "1.0.0",
"description": "CamTalk 桌面客户端 — 多模态实时 AI 视觉对话助手",
"main": "dist/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"build:frontend": "cd ../frontend && npm run build",
"build:all": "npm run build:frontend && npm run build",
"setup:macos": "bash scripts/setup-macos-permissions.sh"
},
"dependencies": {
"electron-store": "^8.2.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"electron": "^31.0.0",
"electron-vite": "^2.3.0",
"typescript": "^5.5.0",
"vite": "^5.3.0"
},
"build": {
"appId": "com.xengineers.camtalk",
"productName": "CamTalk",
"mac": {
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "assets/entitlements.plist",
"entitlementsInherit": "assets/entitlements.plist",
"extendInfo": {
"NSCameraUsageDescription": "CamTalk 需要使用摄像头进行实时 AI 视觉对话",
"NSMicrophoneUsageDescription": "CamTalk 需要使用麦克风进行语音对话"
}
}
}
}

69
desktop/preload/index.ts Normal file
View File

@@ -0,0 +1,69 @@
// ============================================================
// Preload 脚本 — 安全桥接 Main Process 与 Renderer
// 职责:通过 contextBridge 暴露安全的 API 给前端
// ============================================================
import { contextBridge, ipcRenderer } from 'electron'
// 暴露给渲染进程的 API
const electronAPI = {
/**
* 获取后端 WebSocket 地址
*/
getBackendUrl: (): string => {
return ipcRenderer.sendSync('get-backend-url') as string
},
/**
* 设置后端 WebSocket 地址
*/
setBackendUrl: (url: string): void => {
ipcRenderer.send('set-backend-url', url)
},
/**
* 获取应用版本号
*/
getVersion: (): string => {
return ipcRenderer.sendSync('get-version') as string
},
/**
* 请求摄像头权限(触发 macOS 系统弹窗)
* 必须在用户交互事件中调用,否则系统弹窗可能被忽略
*/
requestCameraAccess: async (): Promise<boolean> => {
return await ipcRenderer.invoke('request-camera-access') as boolean
},
/**
* 请求麦克风权限(触发 macOS 系统弹窗)
*/
requestMicAccess: async (): Promise<boolean> => {
return await ipcRenderer.invoke('request-mic-access') as boolean
},
/**
* 监听权限授予事件(主进程检测到用户在系统设置中授权后触发)
* callback 参数 type: 'camera' | 'microphone'
*/
onPermissionGranted: (callback: (type: string) => void): void => {
ipcRenderer.on('permission-granted', (_event, type: string) => callback(type))
},
/**
* 平台信息
*/
platform: process.platform,
/**
* 判断是否在 Electron 环境中运行
*/
isElectron: true,
}
// 通过 contextBridge 安全地暴露 API
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
// 类型声明,让前端 TypeScript 能识别
export type ElectronAPI = typeof electronAPI

View File

@@ -0,0 +1,113 @@
#!/bin/bash
# ============================================================
# macOS 开发环境权限设置脚本
# 用途:为 Electron 开发模式配置摄像头/麦克风权限
# 使用npm run setup:macos
# ============================================================
set -e
E="node_modules/electron/dist/Electron.app"
FW="$E/Contents/Frameworks"
ELECTRON_PLIST="$E/Contents/Info.plist"
ENTITLEMENTS_FILE="assets/entitlements.plist"
BUNDLE_ID="com.github.Electron"
HELPER_BUNDLE_ID="com.github.Electron.helper"
echo "=========================================="
echo " CamTalk macOS 开发权限配置"
echo "=========================================="
# 检查是否在正确的目录
if [ ! -f "$ELECTRON_PLIST" ]; then
echo "[ERROR] 未找到 Electron: $ELECTRON_PLIST"
echo " 请在 desktop/ 目录下运行此脚本"
exit 1
fi
echo ""
echo "[1/4] 检查 Info.plist 中的权限描述..."
if /usr/libexec/PlistBuddy -c "Print :NSCameraUsageDescription" "$ELECTRON_PLIST" >/dev/null 2>&1; then
echo " NSCameraUsageDescription 已存在"
else
echo " 添加 NSCameraUsageDescription..."
/usr/libexec/PlistBuddy -c "Add :NSCameraUsageDescription string 'CamTalk 需要使用摄像头进行实时 AI 视觉对话'" "$ELECTRON_PLIST"
fi
if /usr/libexec/PlistBuddy -c "Print :NSMicrophoneUsageDescription" "$ELECTRON_PLIST" >/dev/null 2>&1; then
echo " NSMicrophoneUsageDescription 已存在"
else
echo " 添加 NSMicrophoneUsageDescription..."
/usr/libexec/PlistBuddy -c "Add :NSMicrophoneUsageDescription string 'CamTalk 需要使用麦克风进行语音对话'" "$ELECTRON_PLIST"
fi
echo ""
echo "[2/4] 对 Electron 所有组件进行签名(含 entitlements + hardened runtime..."
if [ -f "$ENTITLEMENTS_FILE" ] && [ -d "$E" ]; then
# 先移除所有签名
echo " 移除旧签名..."
codesign --remove-signature "$E" 2>/dev/null || true
for fw in "$FW"/*.framework; do
codesign --remove-signature "$fw" 2>/dev/null || true
done
for h in "$FW"/*.app; do
codesign --remove-signature "$h" 2>/dev/null || true
done
# 签名所有 Framework
echo " 签名 Frameworks..."
for fw in "$FW"/*.framework; do
codesign --force --sign - --entitlements "$ENTITLEMENTS_FILE" --options runtime "$fw" 2>/dev/null
done
# 签名所有 Helper Apps关键renderer helper 需要 camera entitlement
echo " 签名 Helper Apps..."
for h in "$FW"/*.app; do
codesign --force --sign - --entitlements "$ENTITLEMENTS_FILE" --options runtime "$h" 2>/dev/null
done
# 签名主 App
echo " 签名主 App..."
codesign --force --sign - --entitlements "$ENTITLEMENTS_FILE" --options runtime "$E" 2>/dev/null
# 验证
if codesign --verify --deep --strict "$E" 2>/dev/null; then
echo " ✅ 签名验证通过"
else
echo " ⚠️ 签名验证有警告(通常不影响运行)"
fi
else
echo " 跳过签名(文件不存在)"
fi
echo ""
echo "[3/4] 重置 TCC 权限..."
echo " 清除旧的摄像头/麦克风权限记录,使下次启动时重新请求。"
tccutil reset Camera "$BUNDLE_ID" 2>/dev/null && echo " Camera ($BUNDLE_ID) 已重置" || true
tccutil reset Microphone "$BUNDLE_ID" 2>/dev/null && echo " Microphone ($BUNDLE_ID) 已重置" || true
tccutil reset Camera "$HELPER_BUNDLE_ID" 2>/dev/null && echo " Camera ($HELPER_BUNDLE_ID) 已重置" || true
tccutil reset Microphone "$HELPER_BUNDLE_ID" 2>/dev/null && echo " Microphone ($HELPER_BUNDLE_ID) 已重置" || true
echo ""
echo "[4/4] 验证签名状态..."
echo " Main App: $(codesign -dvvv "$E" 2>&1 | grep '^flags=' | head -1)"
RENDERER_HELPER="$FW/Electron Helper (Renderer).app"
if [ -d "$RENDERER_HELPER" ]; then
echo " Renderer Helper: $(codesign -dvvv "$RENDERER_HELPER" 2>&1 | grep '^flags=' | head -1)"
fi
echo ""
echo "=========================================="
echo " 配置完成!"
echo "=========================================="
echo ""
echo "下一步:"
echo " npm run dev"
echo ""
echo "启动后应用会自动请求摄像头/麦克风权限。"
echo "如果 macOS 系统弹窗未出现,应用会弹出引导对话框,"
echo "点击按钮一键打开系统设置,授权后应用会自动检测并启用摄像头。"
echo ""

18
desktop/start-dev.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# 启动 CamTalk Desktop 开发环境
DESKTOP_DIR="/Users/cfy/code/XEngineers/CamTalk/CamTalk/desktop"
cd "$DESKTOP_DIR" || exit 1
echo "[INFO] 启动 Electron 开发模式..."
echo "[INFO] 工作目录: $(pwd)"
# 确保 electron-vite 存在
if [ ! -f "node_modules/.bin/electron-vite" ]; then
echo "[ERROR] electron-vite 未安装,请先运行 npm install"
exit 1
fi
# 启动 electron-vite
NODE_ENV=development ./node_modules/.bin/electron-vite dev

22
desktop/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@main/*": ["main/*"],
"@preload/*": ["preload/*"]
}
},
"include": ["main/**/*.ts", "preload/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,205 @@
# CamTalk Electron 桌面端设计计划
## 背景与动机
CamTalk 当前以 Docker 方式部署在 HTTP 服务器上(`http://8.161.227.145:9000`),浏览器的安全上下文策略导致 `navigator.mediaDevices``undefined`,摄像头和麦克风无法使用。通过 Electron 包装为桌面应用后,页面以 `file://` 协议加载Chromium 将其视为安全上下文,`getUserMedia` 可直接正常工作,无需 HTTPS。
## 架构设计
```
Electron 桌面应用
┌──────────────────────────────────────────────┐
│ Main Process (Node.js) │
│ ┌──────────────────────────────────────────┐│
│ │ BrowserWindow 管理 ││
│ │ 系统托盘 / 应用菜单 ││
│ │ 摄像头 & 麦克风权限自动授予session API ││
│ │ 应用生命周期管理 ││
│ └──────────────────────────────────────────┘│
│ ↕ contextBridge │
│ Renderer Process (Chromium) │
│ ┌──────────────────────────────────────────┐│
│ │ 现有 React 前端(几乎零改动) ││
│ │ - CameraManager / MicManager 正常工作 ││
│ │ - VAD (ONNX Runtime Web) 正常工作 ││
│ │ - WebSocket 连接 → Go 后端 ││
│ └──────────────────────────────────────────┘│
│ ↕ WebSocket (ws://) │
│ Go 后端 (已有,无改动) │
│ ┌──────────────────────────────────────────┐│
│ │ :8080 网关服务 ││
│ │ 可以是本地进程 / 远程服务器 ││
│ └──────────────────────────────────────────┘│
└──────────────────────────────────────────────┘
```
## 前端改动评估
结论:前端代码几乎不需要改动。原因如下:
| 模块 | 当前实现 | Electron 下 | 需要改动? |
|------|---------|------------|----------|
| CameraManager | `navigator.mediaDevices.getUserMedia()` | `file://` 下可用 | 否 |
| MicManager | `navigator.mediaDevices.getUserMedia()` | `file://` 下可用 | 否 |
| VAD (@ricky0123/vad-web) | ONNX Runtime Web + AudioWorklet | Electron Chromium 完整支持 | 否 |
| WebSocket | 根据 `window.location` 自动推导 URL | 需确保指向正确的后端地址 | 微调 |
| 配置存储 (localStorage) | 浏览器 localStorage | Electron 原生支持 | 否 |
**唯一需要关注的点**WebSocket URL 的推导逻辑。当前 `websocket.ts` 里是基于 `window.location.host` 推导的Electron 下 `window.location``file://` 协议host 为空。解决方案:在 Electron 的 Main Process 中注入一个环境变量 `VITE_WS_URL`,或者在 Main Process 中通过 preload 脚本暴露后端地址。
## Electron 壳子结构
```
desktop/ # 新建 Electron 项目目录
├── package.json # Electron + 构建依赖
├── electron-builder.yml # 打包配置(可选,后续)
├── main/
│ ├── index.ts # Main Process 入口
│ ├── window.ts # BrowserWindow 创建与配置
│ ├── permissions.ts # 摄像头/麦克风权限自动授予
│ └── tray.ts # 系统托盘(可选)
├── preload/
│ └── index.ts # preload 脚本,通过 contextBridge 暴露配置
└── assets/
├── icon.png # 应用图标
└── icon.icns # macOS 图标
```
### main/index.ts — 核心职责
- 创建 `BrowserWindow`,配置 `webPreferences`
- `preload`: 指向 preload 脚本路径
- `contextIsolation: true`
- `nodeIntegration: false`(安全最佳实践)
- 加载前端内容(两种模式,见下文"加载策略"
- 注册 `session.defaultSession.setPermissionRequestHandler`,自动授予 `media` 权限
- 处理应用生命周期ready / window-all-closed / activate
### main/permissions.ts — 权限自动授予
Electron 默认不会像浏览器那样弹出权限请求弹窗。需要在 Main Process 中显式处理:
```typescript
// 伪代码示意
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
// 自动授予摄像头、麦克风、通知等权限
const allowedPermissions = ['media', 'mediaKeySystem', 'notifications'];
callback(allowedPermissions.includes(permission));
});
session.defaultSession.setPermissionCheckHandler((webContents, permission) => {
return true; // 始终返回已授权
});
```
### preload/index.ts — 安全桥接
通过 `contextBridge` 向渲染进程暴露必要的原生能力:
```typescript
// 伪代码示意
contextBridge.exposeInMainWorld('electronAPI', {
// 后端地址配置
getBackendUrl: () => 'ws://localhost:8080/ws', // 或从配置文件读取
// 应用版本
getAppVersion: () => app.getVersion(),
// 平台信息
platform: process.platform,
});
```
## 加载策略
两种模式各有利弊,推荐**开发阶段用 dev server 模式,生产环境用本地文件模式**
### 模式 ADev Server 模式(开发调试用)
```typescript
// main/index.ts
win.loadURL('http://localhost:5173'); // 连接 Vite 开发服务器
```
优点:热更新、开发体验好,和现有前端开发流程完全一致。
缺点:需要先启动 `npm run dev`
### 模式 B本地文件模式生产环境用
```typescript
// main/index.ts
win.loadFile(path.join(__dirname, '../frontend-dist/index.html'));
```
前端执行 `npm run build` 后,将 `frontend/dist/` 目录的产物复制到 Electron 项目中Electron 通过 `file://` 协议加载。
优点:不依赖任何服务器,双击应用即可运行。
缺点:需要构建步骤。
### WebSocket 连接适配
当前 `websocket.ts` 的 URL 推导逻辑:
```typescript
const WS_URL =
import.meta.env.VITE_WS_URL ||
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
```
Electron 下 `window.location.host` 为空字符串,会导致推导出 `ws:///ws` 这样的无效地址。解决方案有两个:
1. **推荐:通过 preload 注入**。在 `window.electronAPI.getBackendUrl()` 中获取,修改 `websocket.ts` 增加一行 Electron 检测:
```typescript
const WS_URL =
(window as any).electronAPI?.getBackendUrl?.() ||
import.meta.env.VITE_WS_URL ||
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
```
2. **备选:.env 文件**。在 Electron 项目中设置 `VITE_WS_URL=ws://localhost:8080/ws`,构建时 Vite 会将其内联到代码中。
## Go 后端的运行方式
有两种选择,当前阶段推荐方案 1
### 方案 1后端独立运行推荐当前阶段
用户需要自己先在本地或服务器上启动 Go 后端Electron 应用连接到指定地址。配置方式:
- 第一次启动时弹出设置窗口,让用户输入后端地址(如 `ws://localhost:8080/ws` 或 `ws://8.161.227.145:8080/ws`
- 保存到本地配置文件(`electron-store` 或简单 JSON 文件)
- 后续启动自动读取
这种方案改动最小Go 后端完全不需要动。
### 方案 2Electron 内嵌后端(未来优化)
将 Go 编译为二进制文件,打包进 Electron 应用Main Process 启动时作为子进程拉起。用户体验更好(双击即用),但增加打包复杂度。当前阶段不建议。
## 实施步骤
| 阶段 | 内容 | 预估工作量 |
|------|------|----------|
| 1. 初始化 | 在项目根目录新建 `desktop/` 目录,初始化 Electron + TypeScript 项目 | 0.5h |
| 2. Main Process | 编写窗口创建、权限授予、应用生命周期代码 | 1-2h |
| 3. Preload 脚本 | 编写 contextBridge暴露后端地址等配置 | 0.5h |
| 4. 前端适配 | 修改 `websocket.ts` 增加 Electron 环境检测(仅 1 个文件) | 0.5h |
| 5. 开发联调 | Dev Server 模式联调,验证摄像头/麦克风/WebSocket 正常工作 | 1h |
| 6. 本地文件模式 | 配置生产构建流程build → loadFile 验证 | 1h |
| 7. 打包分发(可选) | electron-builder 配置 macOS/Windows 安装包 | 1-2h |
总计约 5-7 小时。
## 风险点与应对
| 风险 | 影响 | 应对策略 |
|------|------|---------|
| ONNX Runtime Web 在 Electron 中的兼容性 | VAD 可能不工作 | Electron 使用完整 ChromiumWebAssembly + AudioWorklet 均支持,风险低。如有问题可在 Main Process 设置 `app.commandLine.appendSwitch('enable-features', 'SharedArrayBuffer')` |
| `@ricky0123/vad-web` 的 AudioWorklet 加载路径 | `file://` 下静态资源路径可能不对 | 确保 `public/` 下的 VAD 模型文件在构建后正确复制。必要时通过 preload 动态注入 worklet 脚本路径 |
| Electron 安全策略限制 media 权限 | 摄像头/麦克风仍不可用 | 通过 `session.setPermissionRequestHandler` 显式授予,这是成熟方案 |
| 远程后端网络不通 | WebSocket 连不上 | Electron 不受 CORS 限制(可在 `webPreferences` 中关闭 `webSecurity` 或设置 CORS headers但网络连通性需要用户自行保证 |
## 不在本次范围内
- Go 后端内嵌打包(方案 2—— 后续优化
- 自动更新机制electron-updater—— 后续优化
- 代码签名与公证macOS notarize—— 正式发布时需要
- 多语言安装包定制 —— 当前使用系统语言

View File

@@ -6,6 +6,18 @@
import { useCallback, useRef, useState } from "react";
import { useI18n } from "../../lib/i18n";
// Electron API 类型
interface ElectronAPI {
requestCameraAccess: () => Promise<boolean>;
isElectron: boolean;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}
export interface CameraManagerHandle {
/** 获取当前视频轨道 */
stream: MediaStream | null;
@@ -13,39 +25,130 @@ export interface CameraManagerHandle {
captureFrame: () => string | null;
}
/** 等待 video 元素接收到第一帧数据 */
function waitForVideoReady(video: HTMLVideoElement, timeoutMs = 10000): Promise<void> {
return new Promise((resolve, reject) => {
if (video.readyState >= 2 && video.videoWidth > 0) {
resolve();
return;
}
const timer = setTimeout(() => {
cleanup();
reject(new Error(`视频加载超时 (readyState=${video.readyState}, paused=${video.paused}, readyState=${video.readyState})`));
}, timeoutMs);
const onLoadedData = () => {
cleanup();
resolve();
};
const onPlaying = () => {
if (video.readyState >= 2 && video.videoWidth > 0) {
cleanup();
resolve();
}
};
function cleanup() {
clearTimeout(timer);
video.removeEventListener("loadeddata", onLoadedData);
video.removeEventListener("playing", onPlaying);
}
video.addEventListener("loadeddata", onLoadedData);
video.addEventListener("playing", onPlaying);
// 主动触发播放autoPlay 在某些环境下不生效)
video.play().catch(() => { /* play 可能在设置 srcObject 期间被中断,重试即可 */ });
});
}
export function useCamera() {
const { t } = useI18n();
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [error, setError] = useState<string | null>(null);
// 防止 React StrictMode 或快速连续调用导致并发执行
const isStartingRef = useRef(false);
/** 停止当前的摄像头流 */
const stopCurrentStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setStream(null);
}, []);
const startCamera = useCallback(async () => {
// 防止并发调用
if (isStartingRef.current) return;
isStartingRef.current = true;
try {
// 清理之前的流(防止 "play interrupted by new load"
stopCurrentStream();
// Electron 环境:先通过 IPC 请求系统级权限(触发 macOS TCC 弹窗)
if (window.electronAPI?.isElectron) {
const granted = await window.electronAPI.requestCameraAccess();
if (!granted) {
setError(t("error.cameraAccess"));
return;
}
}
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: 640, height: 480 },
audio: false,
});
streamRef.current = mediaStream;
setStream(mediaStream);
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
const video = videoRef.current;
if (video) {
video.srcObject = mediaStream;
try {
await waitForVideoReady(video);
} catch (err) {
// play() 可能被新的 load 中断,重试一次
console.warn("[Camera] 首次等待视频就绪失败,重试:", (err as Error).message);
try {
video.play().catch(() => {});
await waitForVideoReady(video, 5000);
} catch {
// macOS TCC 可能阻止了视频帧,但仍保留流(用户可手动授权后重试)
console.warn("[Camera] 视频元素未能加载帧数据(可能 macOS 摄像头权限未授予)");
}
}
}
setError(null);
} catch (err) {
const message = err instanceof Error ? err.message : t("error.cameraAccess");
setError(message);
console.error("[Camera] 获取摄像头失败:", err);
} finally {
isStartingRef.current = false;
}
}, []);
}, [t, stopCurrentStream]);
const stopCamera = useCallback(() => {
stream?.getTracks().forEach((track) => track.stop());
setStream(null);
}, [stream]);
stopCurrentStream();
isStartingRef.current = false;
}, [stopCurrentStream]);
/** 从 video 元素捕获当前帧为 JPEG DataURL */
const captureFrame = useCallback((): string | null => {
const video = videoRef.current;
if (!video || video.readyState < 2) return null;
if (!video || video.readyState < 2 || video.videoWidth === 0) return null;
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;

View File

@@ -6,6 +6,18 @@
import { useCallback, useRef, useState } from "react";
import { useI18n } from "../../lib/i18n";
// Electron API 类型
interface ElectronAPI {
requestMicAccess: () => Promise<boolean>;
isElectron: boolean;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}
export function useMicrophone() {
const { t } = useI18n();
const [stream, setStream] = useState<MediaStream | null>(null);
@@ -14,6 +26,17 @@ export function useMicrophone() {
const startMic = useCallback(async () => {
try {
// Electron 环境:先通过 IPC 请求系统级权限
if (window.electronAPI?.isElectron) {
const granted = await window.electronAPI.requestMicAccess();
if (!granted) {
const msg = t("error.micAccess");
setError(msg);
console.warn("[Mic] macOS 系统麦克风权限被拒绝");
return null;
}
}
const mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,

View File

@@ -6,10 +6,40 @@
import type { ClientMessage, ServerMessage, WsMessage } from "../types";
// WebSocket 地址:优先使用环境变量,否则基于当前页面地址自动推导
const WS_URL =
import.meta.env.VITE_WS_URL ||
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
// Electron 类型声明(通过 preload 脚本注入)
interface ElectronAPI {
getBackendUrl: () => string;
isElectron: boolean;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}
// WebSocket 地址优先级:
// 1. Electron preload 注入的地址(桌面端)
// 2. Vite 环境变量
// 3. 根据当前页面地址自动推导(浏览器端)
function getWsUrl(): string {
// Electron 环境:使用 preload 注入的地址
if (window.electronAPI?.isElectron) {
return window.electronAPI.getBackendUrl();
}
// Vite 环境变量
if (import.meta.env.VITE_WS_URL) {
return import.meta.env.VITE_WS_URL;
}
// 浏览器端:根据当前页面地址推导
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host || "localhost:8080";
return `${protocol}//${host}/ws`;
}
const WS_URL = getWsUrl();
const PING_INTERVAL = 30_000; // 30 秒心跳
const MAX_RECONNECT_DELAY = 30_000; // 最大重连延迟 30 秒