Files
CamTalk/desktop/main/index.ts
2026-06-14 18:10:58 +08:00

249 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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' }
})
})