Phase 4.E + Phase 5 + Phase 3.C 七牛上传:代码侧 SDK 接入(SPM 优先 / Vendor 兜底)

选型(CLAUDE.md ADR-006「SPM 优先 + Vendor xcframework 兜底」):
- 微信 → SPM(Tencent 官方 https://github.com/Tencent/WechatOpenSDK-XCFramework);
  项目方下载的 WechatOpenSDK-NoPay.xcframework 已撤出 Vendor,docs/res 内副本作离线备份
- 高德 → Vendor 手动(官方未提供 SPM;Vendor/AMap/{AMapFoundationKit,AMapLocationKit}.framework
  随仓库分发,fat .framework 同时含 x86_64 + arm64,Do Not Embed)
- 七牛 → SPM(https://github.com/qiniu/objc-sdk v8.9.x)

代码侧(全部 #if canImport 守卫,Xcode 加 SDK 前编译为 no-op):
- Source/SDK/WeChat/WeChatSDK.swift:WXApi.registerApp + handleOpenURL(沿用 msext AppID
  wx586a9b321e56efb7,universalLink 空字符串走非 ULAPI 路径)
- Source/SDK/WeChat/WeChatManager.swift:WXApiDelegate + state UUID 配对的 authorize +
  FIFO 串行 share async/await wrapper
- Source/Login/WeChatAuth.swift:客户端直拼 sns/oauth2/access_token + sns/userinfo →
  7 字段(Province 大写 P / city 经 danbian 去单引号),沿用 msext 同款路径
- Source/SDK/AMap/AMapWrapper.swift:iOS 14+ 隐私合规 3 步 + apiKey 注入
- Source/Location/LocationService.swift:actor + AMapLocationManager 异步包装 + 9 字段
- Source/Network/QiniuConfig.swift:4 项常量沿用 msext(AccessKey/SecretKey/Bucket/Domain)
- Source/Network/QiniuTokenSigner.swift:纯 Swift CryptoKit HMAC-SHA1 + Base64URL 自签
  token(与 msext QiniuManager.m:200-230 等价,不依赖 Qiniu SDK 工作)
- Source/Network/QiniuUploader.swift:actor 包 QNUploadManager async/await

Handler 升级:
- AccreditLoginHandler:拉起授权 → 反向 callback sharelogin 7 字段
- WechatShare:真实链接分享(type=2/3 截图待 Phase 4.F)
- StartLocationHandler:真实定位 → 9 字段(latitude/longitude string, province 小写 p)

生命周期:
- AppDelegate.didFinishLaunchingWithOptions:WeChatSDK.register + AMapWrapper.bootstrap
- SceneDelegate.openURLContexts:WeChatSDK.handleOpenURL 接入回调
- BackGameDataHandler:子游戏 backgameData pop 时 WXApi.delegate=nil + LocationService.stop()

Info.plist:CFBundleURLTypes 加 wx586a9b321e56efb7;LSApplicationQueriesSchemes 追加
weixin/weixinULAPI/weixinURLParamsAPI;NSLocationWhenInUseUsageDescription +
NSMicrophoneUsageDescription(gamehallname 中文文案)。

.gitignore:排除 docs/res/{AMap_iOS_Loc_ALL,objc-sdk-8.9.2}/(235MB 项目方下载副本,
已走 SPM/Vendor 接入不需要副本)。

文档:docs/SDK-Integration-Guide.md 重写 §A 用户手动 Xcode UI 三步(SPM × 2 +
Add Files × 2);Vendor/{AMap,WechatSDK}/README.md 记录各自接入路径选型。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 22:22:23 +08:00
co-authored by Claude Opus 4.7
parent a2baf742b6
commit 1d7e258130
47 changed files with 3226 additions and 273 deletions
@@ -2,18 +2,17 @@
// AccreditLoginHandler.swift
// ylgamehall
//
// H5 Native handler stubaccreditlogin
// H5 Native handleraccreditlogin
// docs/H5-Native-Contract.md §3.1 1 + §3.2 10sharelogin
//
// Phase 4.A stub cb Phase 4.B SDK + AppID +
// Universal Links + /wechat/login fallback
// Phase 4.E
// 1. WeChatAuth.authorize() + oauth2 + userinfo
// (msext NewRootVC.m:2415-2428 沿 AppID/AppSecret/scope)
// 2. bridge.call("sharelogin", data: 7 ) callback
// 1:1 `Province` P / `city` danbian
//
// Phase 4.B
// 1. WeChatManager.shared.authorize() WXAuthCodestate UUID
// 2. WeChatAuth.exchangeForUser(code:) /wechat/login
// sns/oauth2/access_token + sns/userinfo
// 3. bridge.call("sharelogin", data: 7 ) callback
// 1:1 Province Pcity danbian:
// canImport(WechatOpenSDK) SDK stub Phase 4.A
// callback sharelogin Xcode Embed & Sign
//
import Foundation
@@ -21,11 +20,32 @@ import Foundation
public enum AccreditLoginHandler {
public static func register(on bridge: any BridgeProtocol) {
// 1accreditlogin
// cb: "Response from accreditlogin"
// sharelogin callbackPhase 4.B
bridge.register("accreditlogin") { _, callback in
callback?(.string("Response from accreditlogin"))
#if canImport(WechatOpenSDK)
// SDK 7 callback sharelogin
Task { @MainActor in
do {
let user = try await WeChatAuth.authorize()
bridge.call("sharelogin", data: .object([
// 1:1 contract §3.2 [10] / msext NewRootVC.m:2428
"openid": .string(user.openid),
"headimgurl": .string(user.headimgurl),
"nickname": .string(user.nickname),
"sex": .number(Double(user.sex)),
"city": .string(user.city),
"Province": .string(user.Province), // P
"unionid": .string(user.unionid)
]), callback: nil)
} catch WeChatAuthError.userCancelled {
// msext alert sharelogin
} catch {
// msext H5
// Phase 9 Sentry breadcrumb
}
}
#endif
}
}
}
@@ -17,29 +17,24 @@
// msext
//
//
// SDK msext backgameData / cleanUpAction
// stub SDK
// MainActor.run
// SDK SDK
//
// - Phase 4.E SDK`WXApi.delegate = nil`
// msext gameController.m:699 pop SubGameVC
//
// - Phase 5 `LocationService.shared.stop()` + delegate nil
// msext gameController.m:2473-2478 cleanUpAction +
//
//
// - Phase 3.B/C/D / `Recorder.shared.cancel()` +
// `Uploader.shared.cancelInFlight()`
// msext ChatVoiceRecorderVC VC
// cancel pop
//
// - Phase 8 Agora`agoraKit.leaveChannel(...)`
// VideoRoomHandlers stub
// Phase 4.E `WXApi.delegate = nil`canImport Xcode Embed
// Phase 5 `LocationService.shared.stop()`canImport
// Xcode Embed AMapLocationKit
// Phase 3.B/C/D / `AudioRecorder.shared.cancel()` opencore-amr
// `QiniuUploader.cancelInFlight()` Task.cancel
// Phase 8 Agora`agoraKit.leaveChannel(...)`
// VideoRoomHandlers stub
//
//
import Foundation
#if canImport(WechatOpenSDK)
import WechatOpenSDK
#endif
public enum BackGameDataHandler {
public static func register(on bridge: any BridgeProtocol) {
@@ -52,7 +47,25 @@ public enum BackGameDataHandler {
// - .string
// - JSON H5
let payload = serialize(data)
// push SDK pop delegate /
// msext gameController.m:691-709 + cleanUpAction
AudioPlayer.shared.stopAllBackground()
#if canImport(WechatOpenSDK)
// msext gameController.m:699 WXApi delegate VC
WXApi.delegate = nil
#endif
#if canImport(AMapLocationKit)
// msext gameController.m:2473-2478 cleanUpAction
LocationService.shared.stop()
#endif
// Phase 3.C / Phase 3.B QiniuUploader.cancelInFlight
// Task.cancel AudioRecorder opencore-amr
// AudioRecorder.shared.cancel() // Phase 3.C
AppCoordinator.shared.popSubGame(returningData: payload)
}
callback?(.string("backgameData"))
@@ -2,15 +2,18 @@
// StartLocationHandler.swift
// ylgamehall
//
// H5 Native handlerstartlocation Phase 5 stub
// H5 Native handlerstartlocation Phase 5 SDK
// docs/H5-Native-Contract.md §3.1 20 + §3.2 6
//
// Stub
// - startlocation cb "startlocation" undefinedH5
// - **** getlocationinfo callback SDK
// - H5 getlocationinfo Phase 5 AMapLocationKit
//
// 1. LocationService.shared.requestOnce()actor AMapLocationManager
// 2. bridge.call("getlocationinfo", 9 ) callback
// - latitude / longitude **string**`String(format: "%f")`
// - province p sharelogin.Province
// 3. getlocationinfo({errorCode:12, errorMsg:""})
//
// CLAUDE.md A H5 使
// canImport(AMapLocationKit) SDK LocationService sdkNotLinked
// stub Phase 2 callback getlocationinfo
//
import Foundation
@@ -19,9 +22,41 @@ public enum StartLocationHandler {
public static func register(on bridge: any BridgeProtocol) {
bridge.register("startlocation") { _, callback in
// Phase 2 stub callback
// Phase 5 LocationKit + AMapLocationKit.xcframework
// msext cb
callback?(.string("startlocation"))
#if canImport(AMapLocationKit)
// SDK callback 9
// data == 1 Phase 5.4
Task { @MainActor in
do {
let payload = try await LocationService.shared.requestOnce()
bridge.call("getlocationinfo", data: .object([
"address": .string(payload.address),
"city": .string(payload.city),
"cityCode": .string(payload.cityCode),
"country": .string(payload.country),
"district": .string(payload.district),
"latitude": .string(payload.latitude), // string
"longitude": .string(payload.longitude), // string
"province": .string(payload.province), // p
"street": .string(payload.street)
]), callback: nil)
} catch LocationError.authorizationDenied {
// msext gameController.m:2507
bridge.call("getlocationinfo", data: .object([
"errorCode": .number(12),
"errorMsg": .string("缺少定位权限")
]), callback: nil)
} catch {
// 12msext
bridge.call("getlocationinfo", data: .object([
"errorCode": .number(12),
"errorMsg": .string("缺少定位权限")
]), callback: nil)
}
}
#endif
}
}
}
@@ -0,0 +1,105 @@
//
// LocationService.swift
// ylgamehall
//
// AMapLocationManager /
// §3.2 6 getlocationinfo 9 msext gameController.m:2491-2557
//
// canImport(AMapLocationKit) SDK LocationService
// LocationError.sdkNotLinkedhandler 退 stub
//
import Foundation
import CoreLocation
#if canImport(AMapLocationKit)
import AMapLocationKit
#endif
#if canImport(AMapFoundationKit)
import AMapFoundationKit
#endif
/// 9 1:1 H5 contract §3.2 [6]
/// - latitude / longitude ** string** (`String(format: "%f", ...)`)
/// - province ** p** sharelogin.Province
public struct LocationPayload: Sendable {
public let address: String
public let city: String
public let cityCode: String
public let country: String
public let district: String
public let latitude: String
public let longitude: String
public let province: String
public let street: String
}
public enum LocationError: Error, Sendable {
case sdkNotLinked
case authorizationDenied
case timeout
case underlying(any Error)
}
@MainActor
public final class LocationService {
public static let shared = LocationService()
public init() {}
#if canImport(AMapLocationKit)
private let manager: AMapLocationManager = {
let m = AMapLocationManager()
m.desiredAccuracy = kCLLocationAccuracyHundredMeters
m.locationTimeout = 6
m.reGeocodeTimeout = 3
m.locatingWithReGeocode = true
return m
}()
#endif
/// + msext locAction + completionBlock
public func requestOnce() async throws -> LocationPayload {
#if canImport(AMapLocationKit)
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<LocationPayload, Error>) in
manager.requestLocation(withReGeocode: true) { loc, regeo, err in
if let err = err as NSError? {
// msext 12 errorCodehandler
if err.code == AMapLocationErrorCode.locateFailed.rawValue {
cont.resume(throwing: LocationError.authorizationDenied)
} else {
cont.resume(throwing: LocationError.underlying(err))
}
return
}
guard let loc, let regeo else {
cont.resume(throwing: LocationError.timeout)
return
}
cont.resume(returning: LocationPayload(
address: regeo.formattedAddress ?? "",
city: regeo.city ?? "",
cityCode: regeo.citycode ?? "",
country: regeo.country ?? "",
district: regeo.district ?? "",
latitude: String(format: "%f", loc.coordinate.latitude),
longitude: String(format: "%f", loc.coordinate.longitude),
province: regeo.province ?? "",
street: regeo.street ?? ""
))
}
}
#else
throw LocationError.sdkNotLinked
#endif
}
/// msext gameController.m:2473-2478 cleanUpAction
public func stop() {
#if canImport(AMapLocationKit)
manager.stopUpdatingLocation()
manager.delegate = nil
#endif
}
}
+128
View File
@@ -0,0 +1,128 @@
//
// WeChatAuth.swift
// ylgamehall
//
// OAuth2 msext 4 ViewController
// Code access_token userinfo 7 user
//
// AppSecret = CLAUDE.md
// msext IPA Appsecret 沿 secret
// /wechat/login docs/SDK-Integration-Guide.md §0.1
//
// 1:1 msext NewRootVC.m:2428 sharelogin payload
// openid / headimgurl / nickname / sex / city / Province P/ unionid
//
import Foundation
public struct WeChatUser: Sendable {
public let openid: String
public let headimgurl: String
public let nickname: String
/// number1= 2= 0=H5 sharelogin number
public let sex: Int
/// city / province msext `danbian:` H5 JSON
public let city: String
/// Pmsext NewRootVC.m:2428 payload key "Province"
public let Province: String
public let unionid: String
}
public enum WeChatAuthError: Error, Sendable {
case userCancelled
case authStepFailed(Int)
case tokenRequestFailed(any Error)
case tokenResponseInvalid(String)
case userInfoRequestFailed(any Error)
case userInfoResponseInvalid(String)
}
@MainActor
public enum WeChatAuth {
/// AppSecret沿 msext SGDefineInfo.h:107 sns/oauth2
static let appSecret = "b2792724b9565be23e8f5ba548f117cf"
/// OAuth2 code access_token userinfo 7 user
public static func authorize() async throws -> WeChatUser {
// Step 1: code
let payload: WXAuthCodePayload
do {
payload = try await WeChatManager.shared.authorize()
} catch let WeChatError.authFailed(errCode) {
// msext errCode == -2
if errCode == -2 { throw WeChatAuthError.userCancelled }
throw WeChatAuthError.authStepFailed(errCode)
}
// Step 2: code access_token + openid
let token = try await exchangeAccessToken(code: payload.code)
// Step 3: access_token + openid userinfo7
return try await fetchUserInfo(accessToken: token.accessToken, openid: token.openid)
}
// MARK: - Private OAuth2 step 2/3 directly hitting api.weixin.qq.com
private struct TokenResponse {
let accessToken: String
let openid: String
}
private static func exchangeAccessToken(code: String) async throws -> TokenResponse {
let urlString = "https://api.weixin.qq.com/sns/oauth2/access_token"
+ "?appid=\(WeChatSDK.appID)"
+ "&secret=\(appSecret)"
+ "&code=\(code)"
+ "&grant_type=authorization_code"
guard let url = URL(string: urlString) else {
throw WeChatAuthError.tokenResponseInvalid("bad url")
}
let data: Data
do {
(data, _) = try await URLSession.shared.data(from: url)
} catch {
throw WeChatAuthError.tokenRequestFailed(error)
}
guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let accessToken = obj["access_token"] as? String,
let openid = obj["openid"] as? String
else {
let raw = String(data: data, encoding: .utf8) ?? "<binary>"
throw WeChatAuthError.tokenResponseInvalid(raw)
}
return TokenResponse(accessToken: accessToken, openid: openid)
}
private static func fetchUserInfo(accessToken: String, openid: String) async throws -> WeChatUser {
let urlString = "https://api.weixin.qq.com/sns/userinfo"
+ "?access_token=\(accessToken)"
+ "&openid=\(openid)"
guard let url = URL(string: urlString) else {
throw WeChatAuthError.userInfoResponseInvalid("bad url")
}
let data: Data
do {
(data, _) = try await URLSession.shared.data(from: url)
} catch {
throw WeChatAuthError.userInfoRequestFailed(error)
}
guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
let raw = String(data: data, encoding: .utf8) ?? "<binary>"
throw WeChatAuthError.userInfoResponseInvalid(raw)
}
return WeChatUser(
openid: (obj["openid"] as? String) ?? "",
headimgurl: (obj["headimgurl"] as? String) ?? "",
nickname: (obj["nickname"] as? String) ?? "",
sex: (obj["sex"] as? Int) ?? 0,
// msext danbian: JSON
city: danbian((obj["city"] as? String) ?? ""),
Province: danbian((obj["province"] as? String) ?? ""),
unionid: (obj["unionid"] as? String) ?? ""
)
}
/// msext FuncPublic.danbian:
private static func danbian(_ s: String) -> String {
s.replacingOccurrences(of: "'", with: "")
}
}
@@ -0,0 +1,35 @@
//
// QiniuConfig.swift
// ylgamehall
//
// 沿 msext QiniuConfig.m
//
// SecretKey = CLAUDE.md
// AppSecret msext 沿
//
import Foundation
public enum QiniuConfig {
/// AccessKey沿 msext QiniuConfig.m:12
public static let accessKey = "dQbQLUm1jIuL9PEq4jd6VKB-6pPxPEdg7le9KeBm"
/// SecretKey沿 msext QiniuConfig.m:13 HMAC-SHA1 putPolicy
public static let secretKey = "RCZpwLhAPoQ2sQQyWXzMJc7Po2MyZWfUJeW4Jmfq"
/// 沿 msext QiniuConfig.m:16 putPolicy scope
public static let bucketName = "iosaudio"
/// CDN 沿 msext qiniudomain/
/// msext fallback `iosaudio.daoqi8888.cn` `daoqi88`
/// 沿
public static let cdnDomain = "iosaudio.daoqi88.cn"
/// key msext QiniuConfig.m:22
public static let recordingDirectory = ""
/// 访 URL`http://{cdnDomain}/{key}`msext QiniuManager.m:197
public static func publicURL(forKey key: String) -> String {
"http://\(cdnDomain)/\(key)"
}
}
@@ -0,0 +1,68 @@
//
// QiniuTokenSigner.swift
// ylgamehall
//
// token HMAC-SHA1 + Base64URL
// msext QiniuManager.m:200-230 generateUploadToken
//
// putPolicy
// { "scope": "iosaudio[:key]", "deadline": now + expiresIn }
//
// putPolicy JSON Base64URL HMAC-SHA1(secretKey) Base64URL
// `${AccessKey}:${sign}:${encodedPolicy}`
//
// CryptoKitiOS 13+ Qiniu SDK Swift token
//
import Foundation
import CryptoKit
public enum QiniuTokenSigner {
/// token
/// - Parameters:
/// - key: keyscope = bucketallow overridescope = bucket:key
/// - expiresIn: token 3600
public static func uploadToken(key: String? = nil, expiresIn: TimeInterval = 3600) -> String {
// 1. scope
let scope: String
if let key, !key.isEmpty {
scope = "\(QiniuConfig.bucketName):\(key)"
} else {
scope = QiniuConfig.bucketName
}
let deadline = Int(Date().timeIntervalSince1970 + expiresIn)
// 2. putPolicy JSON key msext scope / deadline
// msext 便
let putPolicy: [String: Any] = [
"scope": scope,
"deadline": deadline
]
let policyData = (try? JSONSerialization.data(withJSONObject: putPolicy, options: [.sortedKeys])) ?? Data()
// 3. Base64URL(putPolicy)
let encodedPolicy = base64URLEncode(policyData)
// 4. HMAC-SHA1(secretKey, encodedPolicy) Base64URL
let secretKeyData = Data(QiniuConfig.secretKey.utf8)
let key = SymmetricKey(data: secretKeyData)
let signature = HMAC<Insecure.SHA1>.authenticationCode(
for: Data(encodedPolicy.utf8),
using: key
)
let encodedSign = base64URLEncode(Data(signature))
// 5. token = AccessKey:encodedSign:encodedPolicy
return "\(QiniuConfig.accessKey):\(encodedSign):\(encodedPolicy)"
}
/// Base64URL: Base64 + -/ _ =
/// SDK `QNUrlSafeBase64.encodeData:`
private static func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
@@ -0,0 +1,97 @@
//
// QiniuUploader.swift
// ylgamehall
//
// actor wrapper `QNUploadManager` async/await
//
// canImport(QiniuSDK) Xcode https://github.com/qiniu/objc-sdk
// Swift Package Dependencies no-opupload
// QiniuUploadError.sdkNotLinked handler stub
//
// UploadedFile H5 `getaudiourl` / `recordSuccess`
//
import Foundation
#if canImport(QiniuSDK)
import QiniuSDK
#endif
public struct UploadedFile: Sendable {
public let fileUrl: String // 访 URLhttp://{domain}/{key}
public let fileName: String //
public let fileKey: String // keypath-on-cdn
public let timeSec: Int //
}
public enum QiniuUploadError: Error, Sendable {
case sdkNotLinked
case fileNotFound(String)
case uploadFailed(any Error)
case responseInvalid
}
public actor QiniuUploader {
public static let shared = QiniuUploader()
public init() {}
///
/// - Parameters:
/// - localFile: URLamr
/// - timeSec: H5 `getaudiourl`
/// - Returns: UploadedFile URL
public func upload(_ localFile: URL, timeSec: Int) async throws -> UploadedFile {
#if canImport(QiniuSDK)
let fm = FileManager.default
guard fm.fileExists(atPath: localFile.path) else {
throw QiniuUploadError.fileNotFound(localFile.path)
}
// key`{recordingDirectory}{uuid}.{ext}` msext QiniuManager.m:55-60
let uuid = UUID().uuidString.lowercased().replacingOccurrences(of: "-", with: "")
let ext = localFile.pathExtension
let key = QiniuConfig.recordingDirectory + uuid + (ext.isEmpty ? "" : ".\(ext)")
// token QiniuTokenSigner / msext QiniuManager
let token = QiniuTokenSigner.uploadToken(key: key)
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<UploadedFile, Error>) in
let mgr = QNUploadManager()
let fileName = localFile.lastPathComponent
mgr.putFile(
localFile.path,
key: key,
token: token,
complete: { info, savedKey, _ in
if let info, info.isOK, let savedKey {
let url = QiniuConfig.publicURL(forKey: savedKey)
cont.resume(returning: UploadedFile(
fileUrl: url,
fileName: fileName,
fileKey: savedKey,
timeSec: timeSec
))
} else if let error = info?.error {
cont.resume(throwing: QiniuUploadError.uploadFailed(error))
} else {
cont.resume(throwing: QiniuUploadError.responseInvalid)
}
},
option: nil
)
}
#else
throw QiniuUploadError.sdkNotLinked
#endif
}
/// BackGameDataHandler
/// QNUploadManager API QNUploadOption.cancellationSignal
/// task Swift Task.cancel withCheckedThrowingContinuation
/// cancellation throw CancellationError
public func cancelInFlight() {
// no-op Task.cancel cancel QNUploadOption cancellationSignal
}
}
@@ -0,0 +1,42 @@
//
// AMapWrapper.swift
// ylgamehall
//
// SDK iOS 14+ 3
// updatePrivacyShow updatePrivacyAgree apiKey
// errorCode 7 KEY
//
// APIKey 沿 msext APIKey.h:14 Bundle ID com.skyapp.ylgamehall
//
// canImport(AMapFoundationKit) Vendor/AMap/*.framework Xcode
// Target Frameworks no-op
//
import Foundation
#if canImport(AMapFoundationKit)
import AMapFoundationKit
#endif
@MainActor
public enum AMapWrapper {
/// APIKey沿 msext APIKey.h:14 Bundle ID com.skyapp.ylgamehall
public static let apiKey = "b0d4a8e3fcbbcc0dd96283b7df6a4494"
/// AppDelegate.didFinishLaunchingWithOptions
/// iOS 14+ updatePrivacyShow updatePrivacyAgree apiKey
public static func bootstrap() {
#if canImport(AMapFoundationKit)
AMapLocationServices.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapLocationServices.updatePrivacyAgree(.didAgree)
AMapServices.shared().apiKey = apiKey
#else
// SDK no-op
#endif
}
}
#if canImport(AMapFoundationKit)
private typealias AMapLocationServices = AMapServices
#endif
@@ -0,0 +1,160 @@
//
// WeChatManager.swift
// ylgamehall
//
// SDK delegate + authorize + share
// WXApiDelegate async/awaitstate UUID
//
// Design §8.5msext WXApiManager.m
//
import Foundation
import UIKit
#if canImport(WechatOpenSDK)
import WechatOpenSDK
#endif
@MainActor
public final class WeChatManager: NSObject {
public static let shared = WeChatManager()
/// scope沿 msext SGDefineInfo.h:104
public static let authScope = "snsapi_message,snsapi_userinfo,snsapi_friend,snsapi_contact"
/// resolve authorize continuationstate UUID
private var authPending: [String: CheckedContinuation<WXAuthCodePayload, Error>] = [:]
/// resolve share continuationFIFO ID
private var sharePending: [CheckedContinuation<Void, Error>] = []
public override init() {
super.init()
}
// MARK: - Authorize
/// msext state = "wechat_sdk" + scope = 4
/// code WeChatAuth.exchangeForUser 7
public func authorize() async throws -> WXAuthCodePayload {
#if canImport(WechatOpenSDK)
let state = UUID().uuidString
return try await withCheckedThrowingContinuation { cont in
authPending[state] = cont
let req = SendAuthReq()
req.scope = Self.authScope
req.state = state
WXApi.send(req)
}
#else
throw WeChatError.sdkNotLinked
#endif
}
// MARK: - Share
public enum ShareScene {
case session //
case timeline //
}
public struct ShareLink: Sendable {
public let url: String
public let title: String
public let desc: String
public let thumbnailURL: String?
public init(url: String, title: String, desc: String, thumbnailURL: String?) {
self.url = url; self.title = title; self.desc = desc; self.thumbnailURL = thumbnailURL
}
}
/// FIFO ID
public func shareLink(_ link: ShareLink, scene: ShareScene) async throws {
#if canImport(WechatOpenSDK)
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
sharePending.append(cont)
let media = WXWebpageObject()
media.webpageUrl = link.url
let message = WXMediaMessage()
message.title = link.title
message.description = link.desc
message.mediaObject = media
let req = SendMessageToWXReq()
req.bText = false
req.message = message
switch scene {
case .session: req.scene = Int32(WXSceneSession.rawValue)
case .timeline: req.scene = Int32(WXSceneTimeline.rawValue)
}
WXApi.send(req)
}
#else
throw WeChatError.sdkNotLinked
#endif
}
// MARK: - Internal: resolve callbacks
fileprivate func resolveAuth(state: String, code: String?, errCode: Int) {
guard let cont = authPending.removeValue(forKey: state) else { return }
if errCode == 0, let code {
cont.resume(returning: WXAuthCodePayload(code: code, state: state))
} else {
cont.resume(throwing: WeChatError.authFailed(errCode))
}
}
fileprivate func resolveShare(errCode: Int) {
guard !sharePending.isEmpty else { return }
let cont = sharePending.removeFirst()
if errCode == 0 {
cont.resume(returning: ())
} else {
cont.resume(throwing: WeChatError.shareFailed(errCode))
}
}
}
// MARK: - WXApiDelegate
#if canImport(WechatOpenSDK)
extension WeChatManager: WXApiDelegate {
public nonisolated func onReq(_ req: BaseReq) {
// req App req
}
public nonisolated func onResp(_ resp: BaseResp) {
// 线BaseResp SDK 线 MainActor.run
let errCode = Int(resp.errCode)
if let auth = resp as? SendAuthResp {
let code = auth.code
let state = auth.state ?? ""
Task { @MainActor in
Self.shared.resolveAuth(state: state, code: code, errCode: errCode)
}
} else if resp is SendMessageToWXResp {
Task { @MainActor in
Self.shared.resolveShare(errCode: errCode)
}
}
}
}
#endif
// MARK: - Public payload / error types SDK canImport 使
public struct WXAuthCodePayload: Sendable {
public let code: String
public let state: String
}
public enum WeChatError: Error, Sendable {
case sdkNotLinked
case authFailed(Int)
case shareFailed(Int)
}
@@ -0,0 +1,43 @@
//
// WeChatSDK.swift
// ylgamehall
//
// OpenSDK 沿 msext AppID docs/SDK-Integration-Guide.md §0
//
// canImport(WechatOpenSDK) Vendor/WechatSDK/WechatOpenSDK.xcframework
// Xcode "Frameworks, Libraries, and Embedded Content" Embed & Sign
// no-opEmbed
//
import Foundation
#if canImport(WechatOpenSDK)
import WechatOpenSDK
#endif
@MainActor
public enum WeChatSDK {
/// AppID沿 msext SGDefineInfo.h:105 kAuthOpenID
public static let appID = "wx586a9b321e56efb7"
/// AppDelegate.didFinishLaunchingWithOptions
/// universalLink 沿 msext ULAPI msext
public static func register() {
#if canImport(WechatOpenSDK)
// Universal Linksmsext / ULAPI
WXApi.registerApp(appID, universalLink: "")
#else
// SDK no-opXcode framework WXApi
#endif
}
/// SceneDelegate.openURLContexts QQ WXApi QQ msext
public static func handleOpenURL(_ url: URL) -> Bool {
#if canImport(WechatOpenSDK)
return WXApi.handleOpen(url, delegate: WeChatManager.shared)
#else
return false
#endif
}
}
+41 -8
View File
@@ -2,14 +2,18 @@
// WechatShare.swift
// ylgamehall
//
// SharePlatform stub
// SharePlatform
//
// Phase 4.E stub SDK + AppID + Universal Links
// Design §8.2 ShareKit + §8.5 / QQ
// canImport(WechatOpenSDK) SDK isInstalled = true / share = .success
// SharePanel Xcode Embed & Sign
//
import Foundation
#if canImport(WechatOpenSDK)
import WechatOpenSDK
#endif
@MainActor
public final class WechatShare: SharePlatform {
@@ -17,15 +21,44 @@ public final class WechatShare: SharePlatform {
public let name = "WeChat"
/// Stub: SDK true SharePanel
/// SDK `WXApi.isWXAppInstalled()`
public var isInstalled: Bool { true }
public var isInstalled: Bool {
#if canImport(WechatOpenSDK)
return WXApi.isWXAppInstalled()
#else
return true // SDK share stub success
#endif
}
public init() {}
public func share(_ content: ShareContent, scene: ShareScene) async -> ShareResult {
// Phase 4.E stub success
// WXApi.send + SendMessageToWXResp + state
#if canImport(WechatOpenSDK)
guard WXApi.isWXAppInstalled() else {
return .notInstalled(platform: name)
}
let wxScene: WeChatManager.ShareScene = (scene == .timeline) ? .timeline : .session
// type == "1" SDK / Phase 4.F
// WXImageObject + WXMediaMessage.thumbData
do {
try await WeChatManager.shared.shareLink(
.init(
url: content.webpageUrl,
title: content.title,
desc: content.desc,
thumbnailURL: nil // type=2/3 Phase 4.F
),
scene: wxScene
)
return .success
} catch let WeChatError.shareFailed(errCode) {
if errCode == -2 { return .cancelled }
return .failed(reason: "WeChat errCode \(errCode)")
} catch {
return .failed(reason: "\(error)")
}
#else
// SDK Phase 4.B stub SharePanel
return .success
#endif
}
}