Skip to content

macOS 空白工程接入晞晗IM

macOS 和 iOS 共用 XHIM Swift Facade、XHIMSwiftUI 组件、Lucide 图标和业务 模型。接入步骤也相同,但二进制包必须包含真正的 macOS slice。

本文只处理 macOS 客户端。开始前只领取 Server URL、测试 User ID、 Conversation ID 和可选 App ID;公开部署配置由 SDK 自动发现。你不需要 数据库、服务端密钥、XHIM 签名证书或手工 Token。服务端操作仅见 XHIM Server 文档

跑通第一条消息后,所有公开方法、事件、模型和错误请查 SDK API Reference

最短路径是“添加二进制 Swift Package → connectsendText”。正式业务 上线时再在账号层增加一个鉴权回调,Window 和页面不参与鉴权细节。

先确认发行包

只使用 Release Manifest 明确声明支持 macOS 的签名包。XCFramework Info.plist 至少应包含你要发布的真实 macOS slice,例如:

text
macos-arm64
macos-x86_64
macos-arm64_x86_64

包内应同时包含 XHIM、可选 XHIMSwiftUI、Privacy Manifest、LICENSE、 NOTICE 和 checksum。macOS App 不应自行组合 iOS 静态库或编译 C++ 内核。

1. 新建工程

  1. Xcode 选择 File → New → Project... → macOS → App
  2. Interface 选择 SwiftUI,Language 选择 Swift。
  3. Minimum Deployment 设为 macOS 12 或发布清单声明的更高版本。
  4. 若启用 App Sandbox,在 Signing & Capabilities → App Sandbox 勾选 Outgoing Connections (Client)

2. 添加包

  1. File → Add Package Dependencies... → Add Local...
  2. 选择包含 macOS slice 的 XHIMSwift-<mode> 根目录;
  3. App Target 选择 XHIM
  4. 需要默认页面时再选择 XHIMSwiftUI

正式发布后改为内部 Swift Package URL,不直接拖入 C++ Library。

3. 初始化和登录

Development 服务端明确开启 Easy Login 后,macOS 端只传 Server URL 和当前 User ID:

swift
import XHIM

XHIMClient.connect(
    server: "https://im-test.customer.com",
    userID: currentUserID,
    onConnecting: {
        print("正在连接")
    },
    onConnectSuccess: { client in
        accountContainer.client = client
    },
    onConnectFailure: { error in
        print(error.code, error.message)
    }
)

connect 映射到底层最小配置:

Core 配置macOS 来源
appID显式 appID,否则公开配置默认值
storageURLstorageRootURL 下按 App ID + User ID 隔离
deployment.bootstrapURLserver
Endpoint Key ID + Public KeyBootstrap 公开配置

Production 仍是一行连接,只把“获取 XHIM 登录票据”的回调安装在账号会话层。 SDK 自动完成调用和续期,Window、ViewModel 和页面不处理票据:

swift
XHIMClient.connect(
    server: "https://im.customer.com",
    userID: accountSession.userID,
    authentication: .business(accountSession.fetchXHIMCredential),
    onConnecting: {},
    onConnectSuccess: { client in
        accountSession.xhimClient = client
    },
    onConnectFailure: { error in
        accountSession.show(error.message)
    }
)

connect 会自动发现 App ID/Endpoint 验签公钥,并在 Application Support 创建不可逆账号目录。确有企业目录策略时才传 storageRootURL,不要放 App Bundle 或 Caches。生命周期 Client 应由 macOS App 的账号会话容器持有,不由 Window 或 Row 创建;多 Window 共享同一账号 Client,多账号使用不同 Client 和 数据库。注销时调用 logout(),退出进程或不再使用时调用 shutdown()

Production 必须使用 .business 与可信 HTTPS/WSS;Release 不允许 .development.localPreview、Easy Login 或匿名降级。业务 Provider 使用 宿主已有登录态向购买方自己的后端取得 XHIM 登录票据,其余生命周期由 SDK 处理。

3.1 状态、会话和首条消息

swift
eventToken = client.addEventListener { event in
    if case .stateChanged(let state) = event {
        print("XHIM state:", state)
    }
}

client.directConversation(
    with: "bob",
    onSuccess: { conversation in
        client.sendText(
            conversationID: conversation.conversationID,
            text: "Hello from macOS",
            onSuccess: { _ in print("消息已入队") },
            onFailure: { error in print(error.message) }
        )
    },
    onFailure: { error in
        print(error.message)
    }
)

client.conversations(
    limit: 50,
    onSuccess: { page in
        self.conversations = page.conversations
    },
    onFailure: { error in
        print(error.message)
    }
)

client.messages(
    conversationID: conversationID,
    limit: 50,
    onSuccess: { page in
        self.messages = page.messages
    },
    onFailure: { error in
        print(error.message)
    }
)

完整回调原型和错误处理见 macOS 回调式 API

Swift Concurrency 项目仍可使用同名异步重载:

swift
let page = try await client.messages(
    conversationID: conversationID,
    limit: 50
)
if let sequence = page.messages.compactMap(\.serverSequence).max() {
    _ = try await client.markConversationRead(
        conversationID: conversationID,
        throughServerSequence: sequence
    )
}

消息进入 Outbox 后即使暂时断网也会持久化。仅对同一条 .failed/.cancelled 消息复用 clientMessageID 调用 retryMessage(clientMessageID:)。分页 Cursor 是不透明 Data,只能原样 传回对应查询。

在线状态与“正在输入”

登录后直接调用,不需要 Window 或 ViewModel 管理 Token/WebSocket:

swift
_ = try await client.publishPresence(.online)
_ = try await client.publishTyping(
    conversationID: conversationID,
    isTyping: true
)
_ = try await client.publishTyping(
    conversationID: conversationID,
    isTyping: false
)

client.events 中处理 .presenceChanged/.typingChanged。只发布开始和 停止输入两种转换,不要逐按键发送;UI 按事件中的 expiresAtMilliseconds 到期清除。TTL 省略时由服务端统一配置。

4. 加入基础聊天 UI

swift
import SwiftUI
import XHIMSwiftUI

XHIMChatView(
    messages: viewModel.messages,
    onSend: viewModel.send,
    onAttachment: viewModel.chooseAttachment
)
.environment(\.xhimTheme, XHIMTheme())

AppKit 项目可用 NSHostingView 承载 SwiftUI 组件;也可以只依赖 XHIM,把 Facade 数据交给自己的 NSTableView/NSCollectionView。UI 层不得直接读取 XHIM SQLite。

4.1 事件驱动刷新

macOS 与 iOS 共用强类型 projectionEvents。账号级 @MainActor ViewModel 持有控制器,订阅建立后先查询一次,之后只调用 SDK 重查:

swift
let projectionRefresh = XHIMProjectionRequeryController(
    client: client
) { _ in
    await viewModel.reloadFromXHIM()
}
projectionRefresh.start()

消息、状态、会话、社交和 Sync 投影事件都带 origin/scope/IDs/revision/ sequence;未知 kind/schema 或 revision 断档执行宽范围重查。事件是可合并失效 通知而不是日志,AppKit/SwiftUI 都不得直接读取 SQLite。控制器在 MainActor 串行执行,账号退出时调用 cancel()

4.2 用户资料、单聊、推送和取消

swift
let me = try await client.currentUserProfile()
let profiles = try await client.userProfiles(
    userIDs: [me.userID, "bob"]
)
let updated = try await client.updateCurrentUserProfile(
    XHIMUserProfileUpdate(displayName: "Alice Mac", bio: "")
)
let direct = try await client.directConversation(with: "bob")
_ = try await client.sendText(
    conversationID: direct.conversationID,
    text: "Hello from macOS"
)

XHIMUserProfileUpdatenil 表示保持不变,空字符串表示明确清空。批量资料 一次最多 100 个不重复 User ID,缺失项从 missingUserIDs 读取。

macOS App 使用 APNs 时,把系统回调的 token 与保存在 Keychain 的安装级 deviceID 注册;结果对象不会回传 token,日志中也不要打印输入:

swift
_ = try await client.registerPushDevice(
    XHIMPushDevice(
        platform: .apns,
        deviceID: pushInstallationID,
        token: providerToken,
        environment: "production",
        locale: Locale.current.identifier
    )
)

// 退出账号或关闭通知:
_ = try await client.disablePushDevice(deviceID: pushInstallationID)

取消外层 Swift Task 会自动取消对应 native request;Client 仍可继续被其他 Window 使用:

swift
let lookup = Task { try await client.userProfiles(userIDs: ["bob"]) }
lookup.cancel()

4.3 服务端历史与账号视图

swift
let history = try await client.getMessageHistory(
    conversationID: conversationID,
    limit: 50
)
if let next = history.continuation {
    _ = try await client.getMessageHistory(
        conversationID: conversationID,
        continuation: next,
        limit: 50
    )
}

if let serverMessageID = history.messages.first?.serverMessageID {
    _ = try await client.deleteMessageForSelf(
        conversationID: conversationID,
        serverMessageID: serverMessageID,
        mutationID: UUID().uuidString
    )
}

if history.latestServerSequence > 0 {
    let cleared = try await client.clearConversation(
        conversationID: conversationID,
        throughServerSequence: history.latestServerSequence,
        mutationID: UUID().uuidString,
        expectedRevision: history.view?.revision ?? 0
    )
    _ = try await client.hideConversation(
        conversationID: conversationID,
        mutationID: UUID().uuidString,
        expectedRevision: cleared.view.revision
    )
}

历史按服务端序号从新到旧返回,continuation 不可自行构造。删除、清空和隐藏 仅改变当前账号视图。一次逻辑操作的 mutationID 在网络重试时必须复用; view.revision 是下一次清空/隐藏的 CAS 值。取消 Swift Task 会继续传到 native request。

4.4 自定义消息、编辑与撤回

swift
import Foundation

struct OrderCard: Codable {
    let orderID: String
    let title: String
}

let custom = XHIMOutgoingMessage(
    contentType: "com.customer.message.order-card",
    contentVersion: 1,
    payload: try JSONEncoder().encode(
        OrderCard(orderID: "order-1001", title: "待付款订单")
    ),
    fallbackText: "[订单] 待付款订单"
)
_ = try await client.sendMessage(
    conversationID: conversationID,
    message: custom
)

if let message = page.messages.first,
   let serverMessageID = message.serverMessageID {
    let edited = try await client.editText(
        conversationID: message.conversationID,
        serverMessageID: serverMessageID,
        mutationID: UUID().uuidString,
        expectedRevision: message.mutationRevision,
        text: "修改后的内容"
    )
    _ = try await client.recall(
        conversationID: edited.conversationID,
        serverMessageID: serverMessageID,
        mutationID: UUID().uuidString,
        expectedRevision: edited.mutationRevision
    )
}

未知自定义类型或版本必须展示 fallbackText。需要集中验证、会话预览和自定义 Renderer 时使用 XHIMMessagePluginRegistry。编辑、撤回以及社交写操作的同一次 重试必须复用原 mutationID,CAS 冲突后先重新查询。

5. 图片、视频和文件

macOS 的 XHIMAttachmentPickerButton 使用 NSOpenPanel 返回宿主可读的文件 URL。将文件复制到账号隔离的持久沙箱后,直接调用 client.sendImagesendVideosendAudiosendFile;Core 在后台计算 SHA-256、持久化任务 并负责鉴权、断点续传和恢复,不需要宿主创建 uploader。

这些方法返回 XHIMMediaTask 只表示持久化受理。收到 .mediaTaskUpdated 后按 task ID 查询终态;cancelMediaTask(taskID:) 才是 持久业务取消,取消外层 Swift Task 只取消当前等待。下载使用 acceptMediaDownload(XHIMMediaDownloadIntent),其中 cacheKey 是私有扁平 标识而非路径。任务、事件和 try client.diagnostics() 均不包含路径、用户凭证 或签名 URL。

下载到达 .completed 后使用 openMediaCacheReader(cacheKey:mediaRef:)reader.chunks() 将校验后的 字节流交给解码器,结束时 await reader.close();Reader 可晚于 Client 关闭, 但同一 Reader 的调用会串行。NWPathMonitor 从不可用恢复为 .satisfied 时调用 try? client.notifyNetworkAvailable(),只作为提前重连提示,既有 退避定时器仍然生效。

6. 联调、登出与 Production 清单

  • Development 局域网模式需要本地网络权限和仅开发配置使用的 ATS 放宽;
  • Production 使用系统信任的 HTTPS/WSS;
  • 两台 Mac 或 Mac + iPhone 使用同一 App ID、已加入同一会话的不同用户验证;
  • 验收包含 Apple Silicon、Intel(若声明支持)、窗口重建、睡眠唤醒、网络切换、 Keychain、数据库恢复、签名、公证和 Hardened Runtime。

账号退出和应用结束:

swift
_ = try? await client.disablePushDevice(deviceID: pushInstallationID)
try await client.logout()
await client.shutdown()
eventTask.cancel()
projectionRefresh.cancel()

连接阶段按 XHIMConnectionError 分支,运行时按 XHIMNativeError.code/domain/stableCode/retryable 分支,不解析可读 message。常见问题依次检查包是否含 macOS slice、App Sandbox 出站权限、 Bootstrap HTTPS、User ID/Conversation ID 是否属于同一租户,以及状态是否到达 .ready

Production 还必须确认:

  • 签名包版本、checksum、Privacy Manifest、LICENSE、NOTICE 和 SBOM 一致;
  • 使用 .business Provider,禁用 Local Preview、Easy Login 和开发 ATS 例外;
  • 会话/历史分页、文本/媒体/自定义消息、已读、编辑撤回、社交治理均通过;
  • 请求取消、幂等重试、断网、睡眠唤醒、多窗口和账号切换均通过;
  • Apple Silicon 与声明支持的 Intel 设备完成真实消费者验收。

当前产物状态和 macOS slice 发布工作以 macOS SDK 产品说明 为准。

7. 好友与群组写操作

macOS 暴露与 iOS 相同的强类型 API;下面覆盖十一种写入:

swift
let request = try await client.sendFriendRequest(
    toUserID: "bob", mutationID: UUID().uuidString)
_ = try await client.resolveFriendRequest(
    requestID: request.requestID, decision: .accept,
    mutationID: UUID().uuidString)
_ = try await client.deleteFriendship(
    peerUserID: "bob", mutationID: UUID().uuidString)
let group = try await client.createGroup(
    title: "项目群", memberUserIDs: ["alice", "bob"],
    mutationID: UUID().uuidString)
let roster = try await client.changeGroupMembers(
    conversationID: group.group.conversationID,
    addUserIDs: ["carol"], expectedRevision: group.group.revision,
    mutationID: UUID().uuidString)
_ = try await client.setBlock(
    blockedUserID: "spam-user", isActive: true,
    mutationID: UUID().uuidString)
let join = try await client.requestGroupJoin(
    conversationID: roster.group.conversationID,
    mutationID: UUID().uuidString)
_ = try await client.resolveGroupJoin(
    requestID: join.request.requestID, decision: .accept,
    mutationID: UUID().uuidString)
_ = try await client.changeGroupGovernance(
    conversationID: roster.group.conversationID,
    expectedRevision: roster.group.revision,
    mutationID: UUID().uuidString,
    change: .setProfile(
        title: "新名称", avatarURL: "", announcement: "", description: ""))
_ = try await client.leaveGroup(
    conversationID: memberGroup.conversationID,
    expectedRevision: memberGroup.revision,
    mutationID: UUID().uuidString)
_ = try await client.dismissGroup(
    conversationID: ownedGroup.conversationID,
    expectedRevision: ownedGroup.revision,
    mutationID: UUID().uuidString)

mutationID 是逻辑写幂等键:精确重试复用,不同参数必须生成新值,否则服务端 返回 IDEMPOTENCY_CONFLICTexpectedRevision 是精确 CAS,冲突后先重查 群组。取消 Swift Task 只取消等待,不回滚已提交事务。错误判断使用 code/domain/stableCode,不得解析 message 或记录敏感资料。 退出和解散的结果均包含权威 groupChangeidempotentReplay;示例中的 memberGroupownedGroup 分别是最新查询到的成员群和本人拥有的群。

附录:离线只读数据库

macOS 与 iOS 共用 XHIMOfflineReader。传入现有账号数据库 URL、数据库绑定的 账号 ID,以及从 Keychain 注入的数据库 Key 即可使用:

swift
let reader = try await XHIMOfflineReader.open(
    databaseURL: accountDatabaseURL,
    accountID: currentUserID,
    keyProvider: {
        try accountKeyStore.databaseKey(accountID: currentUserID)
    }
)
let conversations = try await reader.conversations()
let messages = try await reader.messages(
    conversationID: selectedConversationID,
    cursor: previousPage?.nextCursor
)
await reader.close()

不要把 Key 写入 App 配置或源码。同步 C ABI、SQLite I/O、borrowed view 深拷贝 和句柄串行化都由 SDK 内部处理。更多查询(搜索以及六类社交分页)与 iOS 完整示例见本页离线只读数据库,不需要跳转到 iOS 文档。

附录 C:登录设备管理

swift
let page = try await client.listDeviceSessions()
if let target = page.sessions.first(where: {
    !$0.isCurrent && $0.isActive
}) {
    let result = try await client.revokeDeviceSession(
        sessionID: target.sessionID,
        mutationID: UUID().uuidString
    )
    // 已过期/失活目标会返回 changed == false,属于成功幂等结果。
    print(result.changed)
}
let policy = try client.deviceSessionPolicySnapshot()

policy 是调用方持有的原生同步只读快照,不要求先调用列表,也不执行网络或 磁盘 I/O;登录完成前或登出后会抛出 INVALID_STATE。取消调用方 Task 会 转发通用 request cancel;撤销当前会话时返回 isCurrent == trueisActive == false,应用应返回登录页。不得记录会话 ID、设备 ID 或撤销原因。

附录 D:协议兼容快照(仅诊断/灰度)

swift
let compatibility = try client.compatibilitySnapshot()
print(compatibility.clientProtocolVersion)
print(compatibility.serverProtocolVersion)

普通业务无需处理该快照:登录已经对不兼容服务端 fail-closed。它只用于诊断、 支持包和受控灰度观测;getter 同步读取登录时已验证、深拷贝的内存值,不进行 网络或磁盘 I/O。登录前和登出后会保留原生 INVALID_STATE。不要用 capability 绕过登录结论,也不要记录版本字符串或 capability 值;模型描述只输出协议号和 数量。

XHIM 客户端 SDK 与服务端文档