From 681850af38d08d905b4621c8ed5a614a4ee7e8dd Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 21 Apr 2026 22:07:29 +0800 Subject: [PATCH] chore: initial commit --- .gitignore | 10 ++++ PUBLISH.md | 61 ++++++++++++++++++++ Package.swift | 22 +++++++ Sources/XuqmSDK/Core/ApiClient.swift | 53 +++++++++++++++++ Sources/XuqmSDK/Core/SDKConfig.swift | 26 +++++++++ Sources/XuqmSDK/Core/TokenStore.swift | 18 ++++++ Sources/XuqmSDK/Core/XuqmSDK.swift | 24 ++++++++ Sources/XuqmSDK/IM/ImClient.swift | 80 ++++++++++++++++++++++++++ Sources/XuqmSDK/IM/ImSDK.swift | 40 +++++++++++++ Sources/XuqmSDK/IM/ImTypes.swift | 47 +++++++++++++++ Sources/XuqmSDK/Push/PushSDK.swift | 29 ++++++++++ Sources/XuqmSDK/Update/UpdateSDK.swift | 57 ++++++++++++++++++ XuqmSDK.podspec | 12 ++++ 13 files changed, 479 insertions(+) create mode 100644 .gitignore create mode 100644 PUBLISH.md create mode 100644 Package.swift create mode 100644 Sources/XuqmSDK/Core/ApiClient.swift create mode 100644 Sources/XuqmSDK/Core/SDKConfig.swift create mode 100644 Sources/XuqmSDK/Core/TokenStore.swift create mode 100644 Sources/XuqmSDK/Core/XuqmSDK.swift create mode 100644 Sources/XuqmSDK/IM/ImClient.swift create mode 100644 Sources/XuqmSDK/IM/ImSDK.swift create mode 100644 Sources/XuqmSDK/IM/ImTypes.swift create mode 100644 Sources/XuqmSDK/Push/PushSDK.swift create mode 100644 Sources/XuqmSDK/Update/UpdateSDK.swift create mode 100644 XuqmSDK.podspec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10dfc90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.DS_Store +*.class +target/ +build/ +.gradle/ +*.iml +.idea/ +*.log diff --git a/PUBLISH.md b/PUBLISH.md new file mode 100644 index 0000000..3404c23 --- /dev/null +++ b/PUBLISH.md @@ -0,0 +1,61 @@ +# iOS SDK 发版流程 + +## 方式一:CocoaPods + 私有 Spec 仓库(推荐) + +### 1. 创建私有 Spec Repo(只需一次) + +```bash +# 在 GitLab/GitHub 创建一个空仓库,例如 xuqm-specs +pod repo add xuqm-specs https://your-git-host.com/xuqm/xuqm-specs.git +``` + +### 2. 发版步骤 + +```bash +# 1. 更新 XuqmSDK.podspec 中的 version 字段 +# 2. 提交代码并打 tag +git tag 0.1.0 +git push origin 0.1.0 + +# 3. 验证 podspec +pod spec lint XuqmSDK.podspec --allow-warnings + +# 4. 推送到私有 spec repo +pod repo push xuqm-specs XuqmSDK.podspec --allow-warnings +``` + +### 3. 客户端集成 + +```ruby +# Podfile +source 'https://your-git-host.com/xuqm/xuqm-specs.git' +source 'https://cdn.cocoapods.org/' + +pod 'XuqmSDK', '~> 0.1.0' +``` + +--- + +## 方式二:Swift Package Manager(更现代,适合 Xcode 原生集成) + +SPM 基于 Git 标签发版,无需额外基础设施: + +```bash +# 打 tag 即可发版 +git tag 0.1.0 +git push origin 0.1.0 +``` + +客户端在 Xcode → File → Add Package Dependencies 中填写 Git 仓库地址和版本号。 + +--- + +## 推荐选择 + +| 场景 | 推荐方式 | +|------|---------| +| 纯 Swift/SwiftUI 项目,Xcode 15+ | **SPM**(已有 Package.swift) | +| 需要支持 ObjC 混编 / 已有 Cocoapods 体系 | **CocoaPods 私有 Spec** | +| 两种都要支持 | 同时维护 Package.swift + XuqmSDK.podspec | + +当前项目已同时提供 `Package.swift`(SPM)和 `XuqmSDK.podspec`(CocoaPods),两种方式均可使用。 diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..13ddfee --- /dev/null +++ b/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "XuqmSDK", + platforms: [.iOS(.v16), .macOS(.v13)], + products: [ + .library(name: "XuqmSDK", targets: ["XuqmSDK"]), + ], + targets: [ + .target( + name: "XuqmSDK", + path: "Sources/XuqmSDK", + swiftSettings: [.enableExperimentalFeature("StrictConcurrency")] + ), + .testTarget( + name: "XuqmSDKTests", + dependencies: ["XuqmSDK"], + path: "Tests/XuqmSDKTests" + ), + ] +) diff --git a/Sources/XuqmSDK/Core/ApiClient.swift b/Sources/XuqmSDK/Core/ApiClient.swift new file mode 100644 index 0000000..fdec9f9 --- /dev/null +++ b/Sources/XuqmSDK/Core/ApiClient.swift @@ -0,0 +1,53 @@ +import Foundation + +public struct ApiResponse: Decodable { + public let code: Int + public let status: String + public let data: T? + public let message: String +} + +public final class ApiClient: @unchecked Sendable { + + public static let shared = ApiClient() + private var config: SDKConfig? + private var session: URLSession = .shared + + private init() {} + + func configure(with config: SDKConfig) { + self.config = config + } + + public func request( + path: String, + method: String = "GET", + queryItems: [URLQueryItem]? = nil, + body: (some Encodable)? = nil as String? + ) async throws -> T { + guard let config else { throw URLError(.badURL) } + let tokenStore = await XuqmSDK.shared.tokenStore + + var components = URLComponents(url: config.apiBaseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)! + if let qi = queryItems { components.queryItems = qi } + + var req = URLRequest(url: components.url!) + req.httpMethod = method + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let token = tokenStore?.get() { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let b = body { + req.httpBody = try JSONEncoder().encode(b) + } + + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + + let wrapper = try JSONDecoder().decode(ApiResponse.self, from: data) + guard let result = wrapper.data else { throw URLError(.cannotDecodeContentData) } + return result + } +} diff --git a/Sources/XuqmSDK/Core/SDKConfig.swift b/Sources/XuqmSDK/Core/SDKConfig.swift new file mode 100644 index 0000000..997695b --- /dev/null +++ b/Sources/XuqmSDK/Core/SDKConfig.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct SDKConfig: Sendable { + public let appId: String + public let appKey: String + public let appSecret: String + public let apiBaseURL: URL + public let imWebSocketURL: URL + public let debug: Bool + + public init( + appId: String, + appKey: String, + appSecret: String, + apiBaseURL: URL, + imWebSocketURL: URL, + debug: Bool = false + ) { + self.appId = appId + self.appKey = appKey + self.appSecret = appSecret + self.apiBaseURL = apiBaseURL + self.imWebSocketURL = imWebSocketURL + self.debug = debug + } +} diff --git a/Sources/XuqmSDK/Core/TokenStore.swift b/Sources/XuqmSDK/Core/TokenStore.swift new file mode 100644 index 0000000..288199e --- /dev/null +++ b/Sources/XuqmSDK/Core/TokenStore.swift @@ -0,0 +1,18 @@ +import Foundation + +public final class TokenStore: @unchecked Sendable { + + private let key = "com.xuqm.sdk.token" + + public func save(_ token: String) { + UserDefaults.standard.set(token, forKey: key) + } + + public func get() -> String? { + UserDefaults.standard.string(forKey: key) + } + + public func clear() { + UserDefaults.standard.removeObject(forKey: key) + } +} diff --git a/Sources/XuqmSDK/Core/XuqmSDK.swift b/Sources/XuqmSDK/Core/XuqmSDK.swift new file mode 100644 index 0000000..8a45d39 --- /dev/null +++ b/Sources/XuqmSDK/Core/XuqmSDK.swift @@ -0,0 +1,24 @@ +import Foundation + +@MainActor +public final class XuqmSDK { + + public static let shared = XuqmSDK() + private(set) var config: SDKConfig? + private(set) var tokenStore: TokenStore? + + private init() {} + + public func initialize(config: SDKConfig) { + self.config = config + self.tokenStore = TokenStore() + ApiClient.shared.configure(with: config) + } + + public func requireConfig() -> SDKConfig { + guard let config else { + fatalError("XuqmSDK not initialized. Call XuqmSDK.shared.initialize() first.") + } + return config + } +} diff --git a/Sources/XuqmSDK/IM/ImClient.swift b/Sources/XuqmSDK/IM/ImClient.swift new file mode 100644 index 0000000..6d7308c --- /dev/null +++ b/Sources/XuqmSDK/IM/ImClient.swift @@ -0,0 +1,80 @@ +import Foundation + +public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable { + + public weak var delegate: ImEventDelegate? + private var webSocketTask: URLSessionWebSocketTask? + private var session: URLSession? + private let wsURL: URL + private let token: String + private let appId: String + private var shouldReconnect = false + + public init(wsURL: URL, token: String, appId: String) { + self.wsURL = wsURL + self.token = token + self.appId = appId + super.init() + } + + public func connect() { + shouldReconnect = true + var request = URLRequest(url: wsURL) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + webSocketTask = session?.webSocketTask(with: request) + webSocketTask?.resume() + receiveMessage() + } + + private func receiveMessage() { + webSocketTask?.receive { [weak self] result in + switch result { + case .success(let message): + if case .string(let text) = message { + self?.handleMessage(text) + } + self?.receiveMessage() + case .failure(let error): + self?.delegate?.imClientDidError(error.localizedDescription) + } + } + } + + private func handleMessage(_ text: String) { + guard let data = text.data(using: .utf8), + let msg = try? JSONDecoder().decode(ImMessage.self, from: data) else { return } + if msg.chatType == .group { + delegate?.imClientDidReceiveGroupMessage(msg) + } else { + delegate?.imClientDidReceiveMessage(msg) + } + } + + public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) { + let payload: [String: Any] = [ + "type": "chat.send", + "data": ["appId": appId, "toId": toId, "chatType": chatType.rawValue, + "msgType": msgType.rawValue, "content": content] + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let text = String(data: data, encoding: .utf8) else { return } + webSocketTask?.send(.string(text)) { _ in } + } + + public func disconnect() { + shouldReconnect = false + webSocketTask?.cancel(with: .normalClosure, reason: nil) + } + + public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String?) { + delegate?.imClientDidConnect() + } + + public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + let reasonStr = reason.flatMap { String(data: $0, encoding: .utf8) } + delegate?.imClientDidDisconnect(reason: reasonStr) + } +} diff --git a/Sources/XuqmSDK/IM/ImSDK.swift b/Sources/XuqmSDK/IM/ImSDK.swift new file mode 100644 index 0000000..caaf5af --- /dev/null +++ b/Sources/XuqmSDK/IM/ImSDK.swift @@ -0,0 +1,40 @@ +import Foundation + +@MainActor +public final class ImSDK { + + public static let shared = ImSDK() + private var client: ImClient? + + private init() {} + + public func login(userId: String, nickname: String? = nil, avatar: String? = nil) async throws { + let config = XuqmSDK.shared.requireConfig() + + struct LoginResponse: Decodable { let token: String } + var items = [URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "userId", value: userId)] + if let n = nickname { items.append(URLQueryItem(name: "nickname", value: n)) } + if let a = avatar { items.append(URLQueryItem(name: "avatar", value: a)) } + + let res: LoginResponse = try await ApiClient.shared.request( + path: "/api/im/auth/login", method: "POST", queryItems: items + ) + XuqmSDK.shared.tokenStore?.save(res.token) + client = ImClient(wsURL: config.imWebSocketURL, token: res.token, appId: config.appId) + client?.connect() + } + + public func setDelegate(_ delegate: ImEventDelegate) { + client?.delegate = delegate + } + + public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) { + client?.sendMessage(toId: toId, chatType: chatType, msgType: msgType, content: content) + } + + public func disconnect() { + client?.disconnect() + client = nil + } +} diff --git a/Sources/XuqmSDK/IM/ImTypes.swift b/Sources/XuqmSDK/IM/ImTypes.swift new file mode 100644 index 0000000..67caa58 --- /dev/null +++ b/Sources/XuqmSDK/IM/ImTypes.swift @@ -0,0 +1,47 @@ +import Foundation + +public enum ChatType: String, Codable, Sendable { + case single = "SINGLE" + case group = "GROUP" +} + +public enum MsgType: String, Codable, Sendable { + case text = "TEXT" + case image = "IMAGE" + case video = "VIDEO" + case audio = "AUDIO" + case file = "FILE" + case custom = "CUSTOM" + case location = "LOCATION" + case notify = "NOTIFY" + case revoked = "REVOKED" + case forward = "FORWARD" +} + +public enum MsgStatus: String, Codable, Sendable { + case sent = "SENT" + case delivered = "DELIVERED" + case read = "READ" + case revoked = "REVOKED" +} + +public struct ImMessage: Codable, Sendable { + public let id: String + public let appId: String + public let fromUserId: String + public let toId: String + public let chatType: ChatType + public let msgType: MsgType + public let content: String + public let status: MsgStatus + public let mentionedUserIds: String? + public let createdAt: String +} + +public protocol ImEventDelegate: AnyObject { + func imClientDidConnect() + func imClientDidDisconnect(reason: String?) + func imClientDidReceiveMessage(_ message: ImMessage) + func imClientDidReceiveGroupMessage(_ message: ImMessage) + func imClientDidError(_ error: String) +} diff --git a/Sources/XuqmSDK/Push/PushSDK.swift b/Sources/XuqmSDK/Push/PushSDK.swift new file mode 100644 index 0000000..8b35a89 --- /dev/null +++ b/Sources/XuqmSDK/Push/PushSDK.swift @@ -0,0 +1,29 @@ +import Foundation + +public enum PushVendor: String { + case apns = "APNS" + case fcm = "FCM" +} + +@MainActor +public final class PushSDK { + + public static let shared = PushSDK() + private init() {} + + public func registerToken(_ token: String, userId: String, vendor: PushVendor = .apns) async throws { + let config = XuqmSDK.shared.requireConfig() + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/push/register", + method: "POST", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "userId", value: userId), + URLQueryItem(name: "vendor", value: vendor.rawValue), + URLQueryItem(name: "token", value: token), + ] + ) + } +} + +private struct EmptyResponse: Decodable {} diff --git a/Sources/XuqmSDK/Update/UpdateSDK.swift b/Sources/XuqmSDK/Update/UpdateSDK.swift new file mode 100644 index 0000000..a60d341 --- /dev/null +++ b/Sources/XuqmSDK/Update/UpdateSDK.swift @@ -0,0 +1,57 @@ +import Foundation +import UIKit + +public struct AppUpdateInfo: Decodable, Sendable { + public let needsUpdate: Bool + public let versionName: String? + public let versionCode: Int? + public let changeLog: String? + public let forceUpdate: Bool? + public let appStoreUrl: String? +} + +public struct RnUpdateInfo: Decodable, Sendable { + public let needsUpdate: Bool + public let latestVersion: String + public let downloadUrl: String + public let md5: String + public let minCommonVersion: String + public let note: String +} + +@MainActor +public final class UpdateSDK { + + public static let shared = UpdateSDK() + private init() {} + + public func checkAppUpdate(currentVersionCode: Int) async throws -> AppUpdateInfo { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/v1/updates/app/check", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "platform", value: "IOS"), + URLQueryItem(name: "currentVersionCode", value: "\(currentVersionCode)"), + ] + ) + } + + public func openAppStore(url: String) { + guard let storeURL = URL(string: url) else { return } + UIApplication.shared.open(storeURL) + } + + public func checkRnUpdate(moduleId: String, currentVersion: String) async throws -> RnUpdateInfo { + let config = XuqmSDK.shared.requireConfig() + return try await ApiClient.shared.request( + path: "/api/v1/rn/update/check", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "moduleId", value: moduleId), + URLQueryItem(name: "platform", value: "IOS"), + URLQueryItem(name: "currentVersion", value: currentVersion), + ] + ) + } +} diff --git a/XuqmSDK.podspec b/XuqmSDK.podspec new file mode 100644 index 0000000..3e2d3be --- /dev/null +++ b/XuqmSDK.podspec @@ -0,0 +1,12 @@ +Pod::Spec.new do |s| + s.name = 'XuqmSDK' + s.version = '0.1.0' + s.summary = 'XuqmGroup iOS SDK — IM, Push Notifications, Version Management' + s.homepage = 'https://github.com/xuqinmin/XuqmGroup-iOSSDK' + s.license = { :type => 'MIT' } + s.author = { 'XuqmGroup' => 'dev@xuqm.com' } + s.source = { :git => 'https://github.com/xuqinmin/XuqmGroup-iOSSDK.git', :tag => s.version.to_s } + s.ios.deployment_target = '16.0' + s.swift_version = '5.9' + s.source_files = 'Sources/XuqmSDK/**/*.swift' +end