feat:项目打包成Electron
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,6 +2,10 @@
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# ---- 桌面端 ----
|
||||
desktop/node_modules/
|
||||
desktop/dist/
|
||||
|
||||
# ---- 后端 ----
|
||||
backend/bin/
|
||||
backend/server
|
||||
|
||||
4
desktop/.gitignore
vendored
Normal file
4
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# CamTalk Desktop 依赖
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
4
desktop/.npmrc
Normal file
4
desktop/.npmrc
Normal file
@@ -0,0 +1,4 @@
|
||||
# 淘宝镜像源
|
||||
registry=https://registry.npmmirror.com
|
||||
# Electron 二进制文件镜像
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
84
desktop/README.md
Normal file
84
desktop/README.md
Normal 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 资源加载,仅适用于可信环境
|
||||
30
desktop/assets/entitlements.plist
Normal file
30
desktop/assets/entitlements.plist
Normal 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: 允许使用 JIT(Electron/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>
|
||||
30
desktop/electron.vite.config.ts
Normal file
30
desktop/electron.vite.config.ts
Normal 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
49
desktop/main/config.ts
Normal 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
248
desktop/main/index.ts
Normal 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' }
|
||||
})
|
||||
})
|
||||
42
desktop/main/permissions.ts
Normal file
42
desktop/main/permissions.ts
Normal 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
45
desktop/main/window.ts
Normal 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
2722
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
desktop/package.json
Normal file
39
desktop/package.json
Normal 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
69
desktop/preload/index.ts
Normal 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
|
||||
113
desktop/scripts/setup-macos-permissions.sh
Executable file
113
desktop/scripts/setup-macos-permissions.sh
Executable 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
18
desktop/start-dev.sh
Executable 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
22
desktop/tsconfig.json
Normal 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"]
|
||||
}
|
||||
205
docs/10-Electron桌面端设计计划.md
Normal file
205
docs/10-Electron桌面端设计计划.md
Normal 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 模式,生产环境用本地文件模式**:
|
||||
|
||||
### 模式 A:Dev 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 后端完全不需要动。
|
||||
|
||||
### 方案 2:Electron 内嵌后端(未来优化)
|
||||
|
||||
将 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 使用完整 Chromium,WebAssembly + 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)—— 正式发布时需要
|
||||
- 多语言安装包定制 —— 当前使用系统语言
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 秒
|
||||
|
||||
|
||||
Reference in New Issue
Block a user