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
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"]
}