SDK · Swift/iOS
Pure-Swift client SDK for the imcore IM server. WebSocket + JSON, built on
URLSessionWebSocketTask (zero dependencies). Works on iOS 13+, macOS 10.15+,
tvOS 13+, watchOS 6+.
Versioning & compatibility: this SDK (current release v1.10.0) is versioned on its own line (
clients/SDK_VERSION), independent of the imcore server release tag. It targets the imcore wire protocol as of server v1.2.0; because the protocol is additive/forward-compatible, it works with any server ≥ v1.2.0.
Add it (Swift Package Manager)
.package(path: "../path/to/clients/swift") // or a git URL
// target dependency: .product(name: "ImcoreSDK", package: "ImcoreSDK")
// UI target: .product(name: "ImcoreUIKit", package: "ImcoreSDK")
SwiftUI UIKit and Call Client Kit
ImcoreUIKit is an iOS SwiftUI component target with
ImcoreConversationList, ImcoreMessageList, ImcoreComposer, and
ImcoreCallControls. It depends on ImcoreSDK and exposes stable models for
mapping local/cache data. Add it to an app target with
.product(name: "ImcoreUIKit", package: "ImcoreSDK").
ImcoreCallClient converts the existing RtcApi signaling streams into a
single lifecycle (idle, incoming, outgoing, connecting, connected,
ended, failed). Supply an ImcoreCallMediaEngine implementation for
WebRTC or another provider; the package does not hide or select a media vendor.
Usage
import ImcoreSDK
let im = ImcoreClient(
url: "wss://your-host/acc",
auth: AuthConfig(token: "<jwt>", login: LoginPayload(userId: "1001", userName: "alice")),
connectTimeout: 10,
heartbeatTimeout: 75, // reset by any inbound frame
reconnectJitter: true
// maxRequestBytes: 262144 — local pre-send size check → RequestError 1011; <=0 disables
)
// Observe pushes and status with AsyncStreams:
Task { for await msg in im.events("chat_message") { print("msg", msg) } }
Task { for await s in im.statusStream() { print("status", s) } } // idle/connecting/open/authenticated/closed/reauthRequired
Task { for await _ in im.reconnectedStream() { /* re-pull conversations + cursor history */ } }
// connect() returns AFTER the server acknowledges login, and THROWS if login is
// refused (e.g. bad token) — always guard it. (All `try await` calls below must
// run inside an `async throws` context such as this do/catch or a Task.)
do {
try await im.connect()
// Reads the deployment's effective contract and applies maxRequestBytes
// when no local override was configured.
let limits = try await im.refreshMessageLimits()
print("request limit: \(limits.maxRequestBytes) bytes")
_ = try await im.dm.send(targetUserId: "1002", text: "hi")
let queued = try await im.dm.sendQueued(targetUserId: "1002", text: "works offline")
print("\(queued.status) \(queued.tempId)")
let history = try await im.dm.history(targetUserId: "1002", limit: 20)
let page = try await im.dm.historyPage(targetUserId: "1002", limit: 20)
// Older: historyPage(targetUserId: "1002", beforeId: page.nextCursor)
// Newer/reconnect: historyPage(targetUserId: "1002", afterId: page.prevCursor)
let cache = await im.cache
let cached = await cache.getMessages("dm:1001:1002")
_ = try await im.push.register(
deviceId: "<stable-device-id>",
platform: "ios",
pushToken: "<apns-token>",
voipToken: "<pushkit-voip-token>", // optional; RTC calls only
vendor: "apns",
appId: "<bundle-id>"
)
// Escape hatch for any command not yet wrapped:
_ = try await im.request("chat_group_create", ["name": "team"])
try await im.send("chat_dm_typing", ["targetUserID": "1002"]) // fire-and-forget (still throws on local size precheck)
} catch {
print("imcore error: \(error)")
}
For iOS call alerts, keep one ImcoreVoIPCallCoordinator alive for the app
lifetime. Register its onTokenUpdate value as voipToken above. The
coordinator reports incoming PushKit payloads to CallKit immediately and
surfaces answer/end/mute/audio-session callbacks; connect those callbacks to
your existing RtcApi and media engine. It is intentionally unavailable on
macOS/tvOS/watchOS and does not implement media transport.
refreshMessageLimits() calls authenticated GET /user/message-limits and
returns the message-limits.v1 snapshot. The call is explicit so Apple apps
control HTTP availability; an explicit maxRequestBytes, including <= 0 to
disable local checking, always wins. HTTP, authentication, business-code, or
schema failures leave the previous local limit unchanged.
Reconnect catch-up(重连补拉)
im.reconnectedStream() yields on every successful reconnect (not the
first connect()). The SDK does not auto-reconcile state across a reconnect
gap — a typical consumer re-pulls what changed while offline:
Task {
for await _ in im.reconnectedStream() {
// 1. Re-pull conversation lists — labels/mute/unread may have changed while offline.
let dms = try await im.dm.conversations()
let groups = try await im.group.conversations()
let rooms = try await im.room.conversations()
// 2. For conversations still open in the UI, backfill with the cursor
// you remembered from the last rendered page — afterId, not beforeId.
let page = try await im.dm.historyPage(targetUserId: "1002", afterId: lastKnownCursor)
// 3. Multi-device: pull the cross-device conversation-state snapshot too.
_ = try await im.operations.deviceSyncState()
}
}
historyPage results do not get written into im.cache — reconciling
paged history against the local cache is the app’s responsibility.
Conversations you’ve been removed from (e.g. kicked from a group) are already
purged from the cache by the removed push as it arrives, and the server’s
own conversation list won’t include them again after reconnect either — there
is nothing left to reconcile for those.
Customer-service workspace
Customer-service commands have typed request/response DTOs under
im.customerService:
let workspace = try await im.customerService.workspaceBootstrap()
let routing = try await im.customerService.transfer(CSTransferRequestWire(
targetUserID: "customer-1001",
toAgentUserID: "agent-2002",
note: "Billing specialist requested"
))
let profile = try await im.customerService.profileGet(
CSProfileGetRequestWire(targetUserID: "customer-1001")
)
_ = try await im.customerService.noteAdd(
CSNoteAddRequestWire(targetUserID: "customer-1001", body: "Requested a refund")
)
_ = try await im.customerService.claimGroup(
CSGroupClaimRequestWire(groupID: 7, customerUserID: "customer-1001")
)
Task {
for await event in im.customerService.onDMRoutingChanged() {
print("routing changed", event.conversation_id, event.routing_mode)
}
}
AI streaming (customer-service bots)
for await ev in im.ai.ask(targetUserID: "bot:cs", text: "退款怎么弄") {
switch ev {
case let .delta(_, fullSoFar, _): render(fullSoFar)
case let .done(_, fullText, _): finalize(fullText)
case let .error(_, code, _): showError(code)
}
}
// 或观察某会话:
for await ev in im.ai.replies(conversationID: "dm:1001:bot:cs") { /* ... */ }
.done 的 fullText 为权威全文;结束/取消 Task 即“退订”。
收到首个流帧的 messageID 后,可调用 try await im.ai.cancel(messageID: messageID)
请求服务端停止;服务端取消以 .error(code: "canceled") 结束。结束 Task 仍只影响
本地订阅;首帧前可把 chat_dm_send ACK 中的问题消息 ID 传给
try await im.ai.cancelRequest(requestMessageID: requestMessageID),取消排队中或尚未
产生首帧的请求。取消受理是尽力而为;若最终 DM 已进入落库边界,落库正文和 .done
仍可能胜出。
ask() 具备断线恢复:重连(reconnectedStream())后若本次 ask() 仍未
终态,会做一次历史核对——命中则以落库全文交付 .done;若空闲超时内
始终没有匹配帧,则以 .error(code: "stream_timeout") 结束,不再永久挂起。
ask() 使用流帧的 request_message_id 与问题消息精确关联;早于
chat_dm_send ACK 到达的帧会暂存,ACK 返回后只回放本请求帧。旧服务端缺少该字段时
仍兼容按首帧绑定。
for await ev in im.ai.ask(targetUserID: "bot:cs", text: "退款怎么弄", idleTimeoutMs: 15_000) {
switch ev {
case let .delta(_, fullSoFar, _): render(fullSoFar)
case let .done(_, fullText, _): finalize(fullText)
case let .error(_, code, _):
if code == "stream_timeout" { retry() } else { showError(code) }
}
}
- 空闲超时:每次收到匹配的 delta/done/error 帧都会重置计时;
ask()的idleTimeoutMs参数按次覆盖默认值(30000,<= 0关闭计时器)。标准im.ai(Client.swift的ai计算属性)固定用该默认值——如需改客户端 级默认,需自行以AIApi(client, scheduler:, defaultIdleTimeoutMs:)构造。 - 重连恢复:bot 回复无条件落库,重连时若本次
ask()仍在途,会做一次 范围历史读取;命中则直接从历史全文交付.done,未命中则重置空闲窗口 继续等。 im.ai.replies(...)(开放观察者)不带上述超时/恢复,只是被动订阅。
Message lifecycle (edit / recall / delete / typing / receipts)
dm addresses the peer explicitly (targetUserID); group/room act on the
active conversation (no id argument — server infers it from the connection).
receipts (dm/group only) reports read/delivery status for a batch of
message ids.
// dm
_ = try await im.dm.edit(targetUserID: "1002", messageID: "42", text: "fixed typo")
_ = try await im.dm.recall(targetUserID: "1002", messageID: "42")
_ = try await im.dm.delete(targetUserID: "1002", messageID: "42")
_ = try await im.dm.setTyping(targetUserID: "1002", isTyping: true)
_ = try await im.dm.receipts(targetUserID: "1002", messageIDs: ["40", "41", "42"])
// group / room — active conversation, no id
_ = try await im.group.edit(messageID: "42", text: "fixed typo")
_ = try await im.group.recall(messageID: "42")
_ = try await im.group.delete(messageID: "42")
_ = try await im.group.setTyping(isTyping: true)
_ = try await im.group.receipts(groupID: 7, messageIDs: ["40", "41"])
_ = try await im.room.edit(messageID: "42", text: "fixed typo")
_ = try await im.room.recall(messageID: "42")
_ = try await im.room.delete(messageID: "42")
_ = try await im.room.setTyping(isTyping: true)
// observe pushes (each scoped to its own scene; typing/mutation streams are
// pre-filtered by scene so a group observer never sees a dm/room event)
Task { for await t in im.dm.onTyping() { /* t["scene"], t["userID"], t["isTyping"] */ } }
Task { for await m in im.dm.onMutation() { /* m["scene"], m["messageID"], m["type"]: edit|recall|delete */ } }
Task { for await r in im.dm.onReadReceipt() { /* r["messageID"], r["readerUserID"] */ } }
Task { for await r in im.dm.onDeliveryReceipt() { /* r["messageID"], r["receiverUserID"] */ } }
Task { for await r in im.group.onReadReceipt() { /* r["messageID"], r["readerUserID"] */ } }
Message expansion KV (polls / sign-up chains / task status)
Attach settable/deletable Key/Value entries to any message: keys prefixed
u:<userID>: are writable/deletable only by that user (tamper-proof
per-user slots — votes, sign-ups); other keys only by the message sender
(task status etc.). Values are strings — encode structured data as JSON
yourself.
// 投票:发一条"投票"消息,各人把票写进自己的私有槽位
let poll = try await im.group.send(groupId: 7, text: "午饭吃什么?A 火锅 / B 烧烤")
let messageID = poll["messageID"]!.stringValue!
_ = try await im.group.setExpansion(messageID: messageID, entries: ["u:\(myUserID):vote": "A"])
// 计票:客户端遍历 expansions 聚合(服务端不做统计)
Task {
for await m in im.group.onMutation() {
guard m["operation"]?.stringValue == "expansion" else { continue }
let votes = (m["message"]?["expansions"]?.objectValue ?? [:])
.filter { $0.key.hasSuffix(":vote") }
.compactMap { $0.value["value"]?.stringValue }
// votes => ["A", "B", "A", ...]
}
}
// 改票 = 再 set(每 key last-write-wins);弃票 = 删除自己的 key
_ = try await im.group.setExpansion(messageID: messageID, entries: ["u:\(myUserID):vote": "B"])
_ = try await im.group.deleteExpansion(messageID: messageID, keys: ["u:\(myUserID):vote"])
// 任务状态:公共 key(无 u: 前缀)仅消息发送者可写
_ = try await im.dm.setExpansion(targetUserID: "1002", messageID: messageID, entries: ["task.status": "done"])
离线端上线后从历史里直接拿到最新 message["expansions"](无需补事件);单条消息默认上限 100 个 key、单次最多 20 个。
Relations (friends / blocks / whitelist)
_ = try await im.relations.friendAdd(targetUserID: "1002", requestMessage: "你好")
let list = try await im.relations.friendList() // JSONValue: items / userIDs / count
_ = try await im.relations.friendAccept("1002")
_ = try await im.relations.blockAdd("1003")
for await req in im.relations.friendRequestUpdates() {
// req["direction"]?.stringValue, req["status"]?.stringValue
}
Enterprise directory
_ = try await im.enterprise.createDepartment(name: "Engineering", parentID: "root")
let depts = try await im.enterprise.listDepartments()
_ = try await im.enterprise.upsertMember(userID: "1002", departmentID: "d1", title: "Dev")
let members = try await im.enterprise.listMembers(departmentID: "d1", includeChildren: true)
let hits = try await im.enterprise.searchMembers(keyword: "王")
Group governance
Owner/admin-only calls to manage a group, plus directed-visibility send params.
// Governance (owner/admin)
_ = try await im.group.muteAll(groupID: 7, muted: true)
_ = try await im.group.setRole(groupID: 7, targetUserID: "1002", role: "admin")
_ = try await im.group.transferOwner(groupID: 7, targetUserID: "1002")
_ = try await im.group.disband(groupID: 7)
// Join requests: apply to join, list pending requests, approve/reject
_ = try await im.group.apply(groupID: 7, message: "想加入")
let requests = try await im.group.joinRequests()
// raw ChatGroupRequestList: incomingInvites/outgoingInvites/incomingApplies/outgoingApplies
_ = try await im.group.approveRequest(requestID: 123)
_ = try await im.group.rejectRequest(requestID: 123)
_ = try await im.group.setHistoryPolicy(groupID: 7, allowHistoryBeforeJoin: false)
// send()/sendQueued() also accept mention/target params:
_ = try await im.group.send(groupId: 7, text: "@all 通知", mentionAll: true)
_ = try await im.group.send(groupId: 7, text: "@bob", mentionUserIDs: ["1002"])
_ = try await im.group.send(groupId: 7, text: "仅这几位可见", targetUserIDs: ["1002", "1003"])
Realtime signaling (RTC)
im.rtc is a signaling-only wrapper around WebRTC call setup: typed
async send methods (1-1 call invite / accept / reject / hangup / offer /
answer / ICE, plus multi-party mesh join / signal / leave) and typed
AsyncStreams for the matching inbound pushes. The SDK never creates an
RTCPeerConnection or touches media — pair it with a WebRTC stack you own
(e.g. the WebRTC iOS SDK); the SDK only carries the signaling to/from your
peer connection.
// Caller
let invite = try await im.rtc.invite(targetUserID: "1002", callType: "video")
let callID = invite["callID"]?.stringValue ?? ""
// Wire your own RTCPeerConnection's ICE candidates to sendIce, and its
// local offer to sendOffer:
_ = try await im.rtc.sendOffer(callID: callID, targetUserID: "1002", callType: "video", sdp: offerSdp)
_ = try await im.rtc.sendIce(callID: callID, targetUserID: "1002", candidate: candidateSdp, sdpMid: sdpMid, sdpMLineIndex: sdpMLineIndex)
// Callee / peer
Task { for await s in im.rtc.invites() { /* ring UI: s["callID"], s["fromUserID"] */ } }
Task {
for await s in im.rtc.offers() {
// setRemoteDescription from s["sdp"]?.stringValue, create your answer, then im.rtc.sendAnswer(...)
}
}
Task { for await s in im.rtc.iceCandidates() { /* feed into your peer connection */ } }
_ = try await im.rtc.hangup(callID: callID, targetUserID: "1002", reason: "normal")
Multi-party mesh (room/group) uses the same shape: im.rtc.multiJoin(...) /
im.rtc.multiSignal(...) / im.rtc.multiLeave(...) plus
im.rtc.multiStates() / im.rtc.multiSignals() streams — each participant
still runs its own peer connection(s); the SDK only relays the mesh signaling.
Operations (the complete command surface)
im.operations wraps the final 26 registered server commands: conversation
removal/labels, DM policy and AI memory deletion, multi-device snapshots,
presence/status, room governance and compatibility room history.
_ = try await im.operations.setDMPolicy("friends_only")
_ = try await im.operations.subscribePresence(targetUserIDs: ["1002", "1003"])
_ = try await im.operations.setConversationLabels(scene: "dm", labels: ["vip"], targetUserID: "1002")
_ = try await im.operations.setRoomAttributes(roomID: 7, attributes: ["topic": "launch"])
_ = try await im.operations.setRoomMuteAll(roomID: 7, muted: true)
// Manual mark-unread (conversation UX trio, feature 1): flip a conversation's
// unread flag without a new message arriving.
_ = try await im.operations.markUnread(scene: "dm", unread: true, targetUserID: "1002")
Drafts (cross-device compose sync)
im.drafts persists an in-progress compose (text/segments/reply reference)
per conversation server-side, so it follows the user across devices.
_ = try await im.drafts.set(scene: "dm", targetUserID: "1002", text: "还没发送的草稿")
_ = try await im.drafts.clear(scene: "dm", targetUserID: "1002")
Scheduled do-not-disturb (push quiet hours)
im.push.getDnd() / im.push.setDnd() wrap the server’s daily DND window for
push notifications. The SDK maps to/from the friendly field names shown below;
the wire payload uses dndEnabled/dndStartMinutes/etc.
let dnd = try await im.push.getDnd()
_ = try await im.push.setDnd(DndSettings(
enabled: true,
startMinutes: 22 * 60, // 22:00
endMinutes: 8 * 60, // 08:00 next day
timezone: "Asia/Shanghai",
days: 0b1111111 // bitmask, all 7 days
))
Scope (v1)
Transport core + seq-correlated request/response + login-ack-gated connect +
typed DM/group/room/community send/receive/history + local message/conversation
cache + tempID-backed offline send queue + push device registration wrapper +
media upload (im.media).
Not included: E2EE, SwiftUI wrappers.
Development
cd clients/swift
swift build
swift test # unit tests (fake channel + test scheduler, no server)
# Integration test against a live server:
IMCORE_TEST_WS_URL=wss://host/acc IMCORE_TEST_WS_TOKEN=<jwt> \
IMCORE_TEST_USER_ID=1001 IMCORE_TEST_PEER_ID=1002 swift test --filter IntegrationTests
Mint the test token with the repo’s token CLI (same HS256 signing path as the server):
go run ./cmd/demo_token -user 1001 # secret from $IMCORE_AUTH_JWT_SECRET or -secret; issuer/audience default to the demo config