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 stub:accreditlogin — 微信授权登录
// H5 → Native handler:accreditlogin — 微信授权登录
// 契约:docs/H5-Native-Contract.md §3.1 [1] + §3.2 [10]sharelogin
//
// 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() → 拿 WXAuthCode(state UUID 配对)
// 2. WeChatAuth.exchangeForUser(code:) → 后台 /wechat/login 或客户端直拼
// sns/oauth2/access_token + sns/userinfo
// 3. bridge.call("sharelogin", data: 7 字段) 反向 callback
// 字段名严格 1:1 含历史包袱:Province 大写 P、city 经 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) {
// 【1】accreditlogin
// 入参忽略;cb: "Response from accreditlogin"
// 完整实现需触发 sharelogin 反向 callback(Phase 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 handler:startlocation — 定位(Phase 5 完整实现,本期为 stub)
// H5 → Native handler:startlocation — 定位(Phase 5 接入高德 SDK 后激活真实路径)
// 契约:docs/H5-Native-Contract.md §3.1 [20] + §3.2 [6]
//
// Stub 行为:
// - 接收 startlocation 调用,cb "startlocation"(响应不为 undefined,H5 不卡)
// - **不触发** 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 {
// 其它失败也归一到 12(msext 行为)
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.sdkNotLinked,handler 兜底退化为 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 errorCode(handler 转字段)
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
/// 性别。微信回的是 number(1=男 2=女 0=未知);H5 收到的 sharelogin 是同款 number 字面。
public let sex: Int
/// city / province 经 msext `danbian:` 去单引号处理(防 H5 JSON 解析炸)
public let city: String
/// 注意大写 P:msext 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 → userinfo(7 字段)
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}`
//
// ⚠️ 用 CryptoKit(iOS 13+),不依赖 Qiniu SDK,纯 Swift 自签 token。
//
import Foundation
import CryptoKit
public enum QiniuTokenSigner {
/// 生成上传 token。
/// - Parameters:
/// - key: 上传文件 key(可空:scope = bucket,allow override;非空:scope = 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-op,upload 永远抛
// QiniuUploadError.sdkNotLinked。后续 handler 可降级到 stub 路径。
//
// 上传完成后返回 UploadedFile,字段映射给 H5 `getaudiourl` / `recordSuccess`。
//
import Foundation
#if canImport(QiniuSDK)
import QiniuSDK
#endif
public struct UploadedFile: Sendable {
public let fileUrl: String // 公网访问 URL(http://{domain}/{key})
public let fileName: String // 原始本地文件名
public let fileKey: String // 七牛 key(path-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: 本地文件 URL(amr 录音文件)
/// - 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/await,state UUID 配对多发起方。
//
// Design §8.5;msext 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 continuation(state UUID 配对)
private var authPending: [String: CheckedContinuation<WXAuthCodePayload, Error>] = [:]
/// 待 resolve 的 share continuation(FIFO 串行 — 微信回包不带配对 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-op,不影响主工程编译。Embed 后自动激活真实路径。
//
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 Links(msext 同款路径,企业签 / 超签分发场景 ULAPI 无意义)
WXApi.registerApp(appID, universalLink: "")
#else
// SDK 未链接:no-op。Xcode 加完 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
}
}