feat:项目打包成Electron
This commit is contained in:
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user