// ============================================================ // 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 { 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 { 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 { 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' } }) })