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' }
|
|||
|
|
})
|
|||
|
|
})
|