主题
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 → connect → sendText”。正式业务 上线时再在账号层增加一个鉴权回调,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. 新建工程
- Xcode 选择
File → New → Project... → macOS → App。 Interface选择 SwiftUI,Language选择 Swift。- Minimum Deployment 设为 macOS 12 或发布清单声明的更高版本。
- 若启用 App Sandbox,在
Signing & Capabilities → App Sandbox勾选Outgoing Connections (Client)。
2. 添加包
File → Add Package Dependencies... → Add Local...;- 选择包含 macOS slice 的
XHIMSwift-<mode>根目录; - App Target 选择
XHIM; - 需要默认页面时再选择
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,否则公开配置默认值 |
storageURL | storageRootURL 下按 App ID + User ID 隔离 |
deployment.bootstrapURL | server |
| Endpoint Key ID + Public Key | Bootstrap 公开配置 |
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"
)XHIMUserProfileUpdate 中 nil 表示保持不变,空字符串表示明确清空。批量资料 一次最多 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.sendImage、 sendVideo、sendAudio 或 sendFile;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 一致;
- 使用
.businessProvider,禁用 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_CONFLICT。expectedRevision 是精确 CAS,冲突后先重查 群组。取消 Swift Task 只取消等待,不回滚已提交事务。错误判断使用 code/domain/stableCode,不得解析 message 或记录敏感资料。 退出和解散的结果均包含权威 groupChange 与 idempotentReplay;示例中的 memberGroup、ownedGroup 分别是最新查询到的成员群和本人拥有的群。
附录:离线只读数据库
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 == true 且 isActive == 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 值;模型描述只输出协议号和 数量。