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:
co-authored by
Claude Opus 4.7
parent
a2baf742b6
commit
1d7e258130
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user