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") { /* ... */ }

.donefullText 为权威全文;结束/取消 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.swiftai 计算属性)固定用该默认值——如需改客户端 级默认,需自行以 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