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")
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>",
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)")
}
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