feat: add WeChat sharing and invite flow

This commit is contained in:
2026-07-07 16:53:39 +08:00
parent f1937e23d4
commit a80c181bd7
160 changed files with 7557 additions and 849 deletions

View File

@ -0,0 +1,89 @@
//
// WeChatManager.swift
// suixinkan
//
import Foundation
import WechatOpenSDK
/// SDK URL/Universal Link
@MainActor
final class WeChatManager: NSObject {
static let shared = WeChatManager()
/// 便 UI
private(set) var lastShareResult: WeChatShareResult?
private var shareCompletion: ((WeChatShareResult) -> Void)?
private var isRegistered = false
private override init() {
super.init()
}
/// AppID Universal Link
func registerIfNeeded() {
guard !isRegistered else { return }
guard WeChatConfig.validateUniversalLink(WeChatConfig.universalLink) else {
assertionFailure("WeChat Universal Link must use https and end with /")
return
}
isRegistered = WXApi.registerApp(WeChatConfig.appID, universalLink: WeChatConfig.universalLink)
}
/// URL Scheme
@discardableResult
func handleOpenURL(_ url: URL) -> Bool {
WXApi.handleOpen(url, delegate: self)
}
/// Universal Link
@discardableResult
func handleContinueUserActivity(_ userActivity: NSUserActivity) -> Bool {
WXApi.handleOpenUniversalLink(userActivity, delegate: self)
}
///
func sendShareRequest(_ request: SendMessageToWXReq, completion: ((WeChatShareResult) -> Void)? = nil) {
guard WXApi.isWXAppInstalled() else {
completion?(.notInstalled)
return
}
guard WXApi.isWXAppSupport() else {
completion?(.unsupported)
return
}
if let completion {
shareCompletion = completion
}
WXApi.send(request) { [weak self] success in
guard !success else { return }
Task { @MainActor in
self?.finishShare(with: .sendFailed(-1))
}
}
}
private func finishShare(with result: WeChatShareResult) {
lastShareResult = result
shareCompletion?(result)
shareCompletion = nil
}
}
extension WeChatManager: WXApiDelegate {
nonisolated func onReq(_ req: BaseReq) {
//
}
nonisolated func onResp(_ resp: BaseResp) {
guard let shareResp = resp as? SendMessageToWXResp else { return }
let result = WeChatShareResult.fromWXErrorCode(shareResp.errCode)
Task { @MainActor in
WeChatManager.shared.finishShare(with: result)
}
}
}

View File

@ -0,0 +1,73 @@
//
// WeChatShareModels.swift
// suixinkan
//
import Foundation
///
enum WeChatShareScene: Int, CaseIterable, Sendable {
///
case session = 0
///
case timeline = 1
///
case favorite = 2
///
var title: String {
switch self {
case .session:
return "微信好友"
case .timeline:
return "朋友圈"
case .favorite:
return "收藏"
}
}
/// SDK `scene`
var wxSceneValue: Int32 {
Int32(rawValue)
}
}
///
enum WeChatShareResult: Equatable, Sendable {
case success
case cancelled
case notInstalled
case unsupported
case invalidPayload(String)
case sendFailed(Int)
case unknown(Int)
/// SDK
static func fromWXErrorCode(_ code: Int32) -> WeChatShareResult {
switch code {
case 0:
return .success
case -2:
return .cancelled
default:
return .unknown(Int(code))
}
}
}
///
struct WeChatWebPageSharePayload: Equatable, Sendable {
let title: String
let description: String
let url: String
}
///
struct WeChatTextSharePayload: Equatable, Sendable {
let text: String
}
///
struct WeChatImageSharePayload: Equatable, Sendable {
let imageData: Data
}

View File

@ -0,0 +1,115 @@
//
// WeChatShareService.swift
// suixinkan
//
import UIKit
import WechatOpenSDK
///
enum WeChatShareService {
/// Open SDK
static var isWeChatAvailable: Bool {
WXApi.isWXAppInstalled() && WXApi.isWXAppSupport()
}
///
@MainActor
static func shareText(
_ payload: WeChatTextSharePayload,
scene: WeChatShareScene,
completion: ((WeChatShareResult) -> Void)? = nil
) {
let trimmed = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
completion?(.invalidPayload("分享文本不能为空"))
return
}
let request = SendMessageToWXReq()
request.bText = true
request.text = trimmed
request.scene = scene.wxSceneValue
WeChatManager.shared.sendShareRequest(request, completion: completion)
}
///
@MainActor
static func shareWebPage(
_ payload: WeChatWebPageSharePayload,
scene: WeChatShareScene,
thumbImage: UIImage? = nil,
completion: ((WeChatShareResult) -> Void)? = nil
) {
let title = payload.title.trimmingCharacters(in: .whitespacesAndNewlines)
let url = payload.url.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
completion?(.invalidPayload("分享标题不能为空"))
return
}
guard !url.isEmpty else {
completion?(.invalidPayload("分享链接不能为空"))
return
}
let webpageObject = WXWebpageObject()
webpageObject.webpageUrl = url
let message = WXMediaMessage()
message.title = title
message.description = payload.description
message.mediaObject = webpageObject
if let thumbImage, let thumbData = WeChatThumbImageProcessor.makeThumbData(from: thumbImage) {
message.thumbData = thumbData
}
let request = SendMessageToWXReq()
request.bText = false
request.message = message
request.scene = scene.wxSceneValue
WeChatManager.shared.sendShareRequest(request, completion: completion)
}
///
@MainActor
static func shareImage(
_ payload: WeChatImageSharePayload,
scene: WeChatShareScene,
thumbImage: UIImage? = nil,
completion: ((WeChatShareResult) -> Void)? = nil
) {
guard !payload.imageData.isEmpty else {
completion?(.invalidPayload("分享图片不能为空"))
return
}
let imageObject = WXImageObject()
imageObject.imageData = payload.imageData
let message = WXMediaMessage()
message.mediaObject = imageObject
if let thumbImage, let thumbData = WeChatThumbImageProcessor.makeThumbData(from: thumbImage) {
message.thumbData = thumbData
} else if let image = UIImage(data: payload.imageData),
let thumbData = WeChatThumbImageProcessor.makeThumbData(from: image) {
message.thumbData = thumbData
}
let request = SendMessageToWXReq()
request.bText = false
request.message = message
request.scene = scene.wxSceneValue
WeChatManager.shared.sendShareRequest(request, completion: completion)
}
/// App
static func defaultThumbImage() -> UIImage? {
guard let icons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any],
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
let iconFiles = primary["CFBundleIconFiles"] as? [String],
let iconName = iconFiles.last else {
return nil
}
return UIImage(named: iconName)
}
}

View File

@ -0,0 +1,47 @@
//
// WeChatThumbImageProcessor.swift
// suixinkan
//
import UIKit
/// 32KB
enum WeChatThumbImageProcessor {
///
static let maxThumbBytes = 32 * 1024
///
static func makeThumbData(from image: UIImage, maxBytes: Int = maxThumbBytes) -> Data? {
guard maxBytes > 0 else { return nil }
let resized = resize(image, maxPixel: 120)
var quality: CGFloat = 0.82
var data = resized.jpegData(compressionQuality: quality)
while let current = data, current.count > maxBytes, quality > 0.12 {
quality -= 0.1
data = resized.jpegData(compressionQuality: quality)
}
guard let result = data, result.count <= maxBytes else { return nil }
return result
}
/// 使
static func resize(_ image: UIImage, maxPixel: CGFloat) -> UIImage {
let sourceSize = image.size
guard sourceSize.width > 0, sourceSize.height > 0 else { return image }
let maxSide = max(sourceSize.width, sourceSize.height)
guard maxSide > maxPixel else { return image }
let scale = maxPixel / maxSide
let targetSize = CGSize(width: sourceSize.width * scale, height: sourceSize.height * scale)
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
}
}