chore: initial commit
这个提交包含在:
当前提交
681850af38
10
.gitignore
vendored
普通文件
10
.gitignore
vendored
普通文件
@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.class
|
||||||
|
target/
|
||||||
|
build/
|
||||||
|
.gradle/
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
*.log
|
||||||
61
PUBLISH.md
普通文件
61
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),两种方式均可使用。
|
||||||
22
Package.swift
普通文件
22
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"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ApiResponse<T: Decodable>: 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<T: Decodable>(
|
||||||
|
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<T>.self, from: data)
|
||||||
|
guard let result = wrapper.data else { throw URLError(.cannotDecodeContentData) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Sources/XuqmSDK/IM/ImSDK.swift
普通文件
40
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
|
||||||
|
}
|
||||||
|
}
|
||||||
47
Sources/XuqmSDK/IM/ImTypes.swift
普通文件
47
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)
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
@ -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),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
XuqmSDK.podspec
普通文件
12
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
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户