macOS 架构:主进程 / preload 桥 / 渲染进程
macOS 桌面端采用 Electron 的职责划分:主进程独占 IMLib 连接与本地 SQLite,SDK 自带的 preload 通过 contextBridge 暴露 window.niPaasIMBridge,渲染进程只做 UI。理解这条边界是排查“消息能收到但界面不更新”“冷启动没有历史”的关键。
职责矩阵
| 层 | 负责 | 不负责 | 安全设置 |
|---|---|---|---|
| 主进程 main | IMLib 客户端与长连接、事件分发、本地 SQLite 单写者、窗口与菜单、通知/托盘/角标 | 不渲染 DOM、不处理业务页面状态 | 打包时开启 hardened runtime |
| preload(SDK 自带) | 用 `contextBridge` 暴露 `window.niPaasIMBridge`:`imlib.*` / `localDb.*` / `onEvent` / `onState` / `capabilities` | 不承载业务逻辑、不直接读写数据库 | `contextIsolation: true`、`sandbox: false`(ESM preload 必需) |
| 渲染进程 renderer | 宿主 UI(HTML/React/Vue 自渲染)、路由、输入与展示 | 不直连 WebSocket / SQLite / 文件系统 | `nodeIntegration: false` |
数据流
Text
用户操作(渲染进程)
│ window.niPaasIMBridge.imlib.sendTextMessage(1, targetId, text)
▼
preload(固定白名单)→ ipcRenderer.invoke('ni-paas-im:invoke', {ns:'imlib', op, args})
▼
主进程 registerIpc → service(IMLib 客户端 + 本地 SQLite)
│ client.sendTextMessage(...) / 本地落库
▼
IM 服务端(WebSocket)
│ 下行消息 / 回执 / 会话变更
▼
主进程事件 → ipcMain 广播 'ni-paas-im:event' {event,payload,seq,ts} 到订阅窗口
▼
preload onEvent → 渲染进程更新宿主 UI最小可用的主进程骨架
JavaScript
import { app, BrowserWindow, ipcMain } from 'electron';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { initService, Events } from '@ni-paas-im/electron/main';
import { registerIpc } from '@ni-paas-im/electron/main/ipc';
const require = createRequire(import.meta.url);
let win;
let service;
let bridge;
async function createWindow() {
win = new BrowserWindow({
width: 1280,
height: 800,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 16, y: 16 },
webPreferences: {
preload: join(require.resolve('@ni-paas-im/electron/package.json'), '..', 'src', 'preload', 'index.mjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false, // ESM preload 只在非沙箱渲染进程加载
},
});
await win.loadFile('renderer/index.html');
}
app.whenReady().then(async () => {
service = initService({
appkey: 'demo',
serverUrl: 'https://im.example.com',
dbPath: app.getPath('userData'), // 目录
});
bridge = registerIpc({ ipcMain, service }); // service 传 initService() 返回值本身
// 宿主侧(通知 / 托盘 / 角标)可直接订阅主进程事件
service.client.addEventListener(Events.CONNECTED, (payload) => console.log('connected', payload.userId));
service.client.addEventListener(Events.MESSAGES, ({ messages }) => console.log('messages', messages.length));
await createWindow();
});
app.on('window-all-closed', () => {
if (bridge) bridge.dispose();
if (service) service.destroy();
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });preload 与页面 API
- preload 文件由 SDK 提供(`@ni-paas-im/electron/preload`),页面看到 `window.niPaasIMBridge`;不要另写自定义全局桥或自定义 `ipcMain.handle` 通道。
- 桥只暴露固定白名单:`imlib` 100 个 IPC 操作、`localDb` 26 个操作、`onEvent` / `onState` / `capabilities`;`addEventListener` / `removeEventListener` 在渲染进程本地实现。
- `sandbox` 必须为 `false`:Electron 35 只在非沙箱渲染进程加载 ESM `.mjs` preload;`contextIsolation: true` 与 `nodeIntegration: false` 仍是硬要求,页面拿不到 Node。
- 多窗口:SDK 只向已 `subscribe` 的窗口广播事件;宿主无需自己转发事件(通知 / 托盘等主进程能力可直接订阅 `service.client`)。
生命周期与 macOS 特性
| 事件 | macOS 行为 | 建议处理 |
|---|---|---|
| 关闭最后一个窗口 | 应用默认不退出(Dock 仍在) | 保留主进程连接;`activate` 时重建窗口 |
| Cmd+Q | 退出应用 | `before-quit` 中 `bridge.dispose()` + `service.destroy()` |
| 系统休眠 / 唤醒 | 长连接可能被系统挂起 | 监听 `powerMonitor` 的 suspend/resume;唤醒后检查 `getConnectionStatus` |
| 切换网络 | Wi-Fi 切换导致断线 | 依赖 SDK 自动重连(仅网络类错误);`onState` 更新连接态 UI;`1002/1003` 需重新登录 |
| 全屏 / 多桌面 | 窗口尺寸与安全区变化 | resize 时只调整布局,不重建页面 |
不要在渲染进程直连服务端
页面直连 WebSocket 会绕过主进程的本地库、去重与多窗口同步,导致未读与历史不一致;所有通信必须经 preload 桥。