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

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
}