macOS 架构:主进程 / preload 桥 / 渲染进程

macOS 桌面端采用 Electron 的职责划分:主进程独占 IMLib 连接与本地 SQLite,SDK 自带的 preload 通过 contextBridge 暴露 window.niPaasIMBridge,渲染进程只做 UI。理解这条边界是排查“消息能收到但界面不更新”“冷启动没有历史”的关键。

职责矩阵​

层负责不负责安全设置
主进程 mainIMLib 客户端与长连接、事件分发、本地 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 桥。