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
|
||||
正在加载...
在新工单中引用
屏蔽一个用户