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