Files
CamTalk/desktop/main/index.ts

249 lines
8.3 KiB
TypeScript
Raw Normal View History

2026-06-14 18:10:58 +08:00
// ============================================================
// 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' }
})
})