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