249 lines
8.3 KiB
TypeScript
249 lines
8.3 KiB
TypeScript
// ============================================================
|
||
// 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' }
|
||
})
|
||
})
|