微信分享去sdk
This commit is contained in:
@@ -7,22 +7,22 @@
|
||||
// 契约:docs/H5-Native-Contract.md §3.1 [5]mediaTypeAudio + §3.2 [7][8]
|
||||
// gameui_play_voice / gameui_stop_voice
|
||||
//
|
||||
// ## 与原项目的行为对齐
|
||||
// ## 与原项目的行为差异(用户反馈驱动的修正)
|
||||
//
|
||||
// daoqi msext NewRootVC.m / gameController.m 把 voicePlayer / User_id 作
|
||||
// 为 VC 成员变量;本类把同款状态封装为单例。关键行为:
|
||||
// daoqi msext NewRootVC.m / gameController.m 把 voicePlayer / User_id 作为
|
||||
// VC 成员变量,覆盖式播放时直接 `voicePlayer = [[AVAudioPlayer alloc]...]`
|
||||
// 替换 ivar,没有触发任何停旧 player 的回调;自然结束时再用最新的
|
||||
// `self.User_id`(已被新一条消息覆盖)调 stopradio → 发给 H5 的
|
||||
// `gameui_stop_voice` payload 实际是**新一条消息的 user_id**,导致旧消息
|
||||
// 对应的 H5 语音动画永远停不掉。
|
||||
//
|
||||
// - `lastUserId`:mediaTypeAudio 入参 user 字段(每次 mediaTypeAudio 进来
|
||||
// 都更新,无论是否真的播放——对齐 daoqi gameController.m:438
|
||||
// `self.User_id=userid;` 在 canbofang=NO 时也会执行)
|
||||
// - `gameui_stop_voice` payload 用 `lastUserId`,不是 didFinishPlaying 传
|
||||
// 入的 player 关联值(对齐 daoqi stopradio 用 self.User_id 的行为;多条
|
||||
// 消息连发时 gameui_stop_voice 发最新 user,**这是 daoqi 的行为**)
|
||||
// - 覆盖式 play:前一条还在播时收到第二条,直接覆盖 player(让旧的被
|
||||
// ARC/ARC-like 释放);对齐 daoqi `voicePlayer = [[AVAudioPlayer alloc]...]`
|
||||
// 赋值行为
|
||||
// - 主动 stop **不**触发 onFinish(daoqi 没有主动 stop 路径,只有
|
||||
// audioPlayerDidFinishPlaying 触发 stopradio)
|
||||
// 本类把 user 与 player 一一绑定(`currentUser`):
|
||||
// - 覆盖式 play:在替换 player 之前主动 stop 旧 player 并触发
|
||||
// `onFinish(currentUser)`,让 H5 收到正确的 `gameui_stop_voice(被打断 user)`
|
||||
// - 自然结束 / 解码失败:用 `currentUser` 而非任何后写入的"最近 user"
|
||||
// 触发 onFinish,保证停的是真正结束那条
|
||||
// - 主动 stop():不触发 onFinish(保留 daoqi 语义;本项目目前也没有外部
|
||||
// 主动 stop 调用路径)
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
@@ -36,42 +36,57 @@ public final class VoicePlayer: NSObject, AVAudioPlayerDelegate {
|
||||
|
||||
private var player: AVAudioPlayer?
|
||||
|
||||
/// 最近一次 mediaTypeAudio 入参中的 user 字段。
|
||||
/// 对齐 daoqi self.User_id 行为(成员变量,每次 mediaTypeAudio 进来都更新)。
|
||||
public private(set) var lastUserId: String = ""
|
||||
/// 当前正在播放的语音对应的 user。与 `player` 一一绑定:`player != nil` 时一定有值,
|
||||
/// `player == nil` 时为 nil。覆盖式 play / didFinishPlaying / decodeError 都用这个
|
||||
/// 值触发 onFinish,确保 `gameui_stop_voice` payload 与真正结束 / 被中断的语音对应。
|
||||
private var currentUser: String?
|
||||
|
||||
/// 播放完成回调(AVAudioPlayer didFinishPlaying 触发,成功/失败都触发)。
|
||||
/// mediaTypeAudio handler 注册此 closure 触发反向调用 gameui_stop_voice。
|
||||
/// payload 是 `lastUserId`(不是 player 关联的 user,对齐 daoqi 行为)。
|
||||
/// 播放完成 / 被中断 / 解码失败统一回调。
|
||||
/// mediaTypeAudio handler 注册此 closure → 反向调 H5 `gameui_stop_voice`。
|
||||
/// payload 始终是与刚结束 / 被打断 player 绑定的 user(不是入参中最新的 user)。
|
||||
public var onFinish: ((String) -> Void)?
|
||||
|
||||
private override init() { super.init() }
|
||||
|
||||
/// 记录 mediaTypeAudio 入参的 user,**无论是否实际播放都要调**。
|
||||
/// 对齐 daoqi gameController.m:438(voicePlaying=0 时仍执行 `self.User_id=userid`)。
|
||||
public func recordIncomingUser(_ user: String) {
|
||||
lastUserId = user
|
||||
}
|
||||
/// 播放 wav。若当前已有正在播放的 player,先 stop 旧 player 并触发
|
||||
/// `onFinish(被打断的 user)`,让 H5 把旧动画停掉;再启动新 player。
|
||||
/// - Throws: AVAudioPlayer 创建 / 启动失败
|
||||
public func play(_ wav: URL, user: String) throws {
|
||||
// 1. 若已有 player 正在播放,主动通知"被中断",确保 H5 把旧动画停掉。
|
||||
// delegate 先置 nil 防止旧 player 的 stop / dealloc 后续异步触发
|
||||
// didFinishPlaying 又再发一次 onFinish。
|
||||
if let old = player {
|
||||
old.delegate = nil
|
||||
old.stop()
|
||||
player = nil
|
||||
if let interrupted = currentUser {
|
||||
currentUser = nil
|
||||
Self.log.debug("interrupted user=\(interrupted, privacy: .public)")
|
||||
onFinish?(interrupted)
|
||||
} else {
|
||||
currentUser = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 播放 wav。若已有正在播放的 player,直接覆盖(对齐 daoqi 行为)。
|
||||
/// 调用方应在调用前调 `recordIncomingUser(user)` 更新 lastUserId。
|
||||
/// - Throws: AVAudioPlayer 创建/启动失败
|
||||
public func play(_ wav: URL) throws {
|
||||
// 2. 创建并启动新 player
|
||||
let p = try AVAudioPlayer(contentsOf: wav)
|
||||
p.delegate = self
|
||||
guard p.prepareToPlay(), p.play() else {
|
||||
throw PlayerError.startFailed
|
||||
}
|
||||
player = p
|
||||
Self.log.debug("play \(wav.lastPathComponent, privacy: .public) user=\(self.lastUserId, privacy: .public)")
|
||||
currentUser = user
|
||||
Self.log.debug("play \(wav.lastPathComponent, privacy: .public) user=\(user, privacy: .public)")
|
||||
}
|
||||
|
||||
/// 主动停止当前播放(不触发 onFinish)。
|
||||
/// 外部清理使用(如 H5 BackGameData 退出子游戏时);
|
||||
/// 业务正常路径下不会主动调,由 didFinishPlaying 自然完成。
|
||||
public func stop() {
|
||||
player?.delegate = nil
|
||||
player?.stop()
|
||||
player = nil
|
||||
currentUser = nil
|
||||
}
|
||||
|
||||
public enum PlayerError: Error, Sendable {
|
||||
@@ -110,8 +125,10 @@ public final class VoicePlayer: NSObject, AVAudioPlayerDelegate {
|
||||
/// 的 player 与当前 self.player 同身份时才清理 + 触发 onFinish。
|
||||
private func handleFinish(playerIdentity: ObjectIdentifier, success: Bool) {
|
||||
guard let current = player, ObjectIdentifier(current) == playerIdentity else { return }
|
||||
let user = currentUser
|
||||
player = nil
|
||||
Self.log.debug("didFinish user=\(self.lastUserId, privacy: .public) success=\(success, privacy: .public)")
|
||||
onFinish?(lastUserId)
|
||||
currentUser = nil
|
||||
Self.log.debug("didFinish user=\(user ?? "<nil>", privacy: .public) success=\(success, privacy: .public)")
|
||||
if let user { onFinish?(user) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,161 +2,196 @@
|
||||
// WechatShare.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// 微信分享 SharePlatform 实现 + 大厅专用 shareLobby() 路径。
|
||||
// 微信分享 SharePlatform 实现:剪贴板 + 引导框 + `weixin://` URL Scheme,
|
||||
// 与 QQShare / DouyinShare 同款交互模式,**不依赖微信 OpenSDK 的分享 API**。
|
||||
//
|
||||
// 两条路径对应原 msext 两个 ViewController 中同名 handler 的差异行为:
|
||||
// 设计取舍:
|
||||
// - 微信 OpenSDK 的 sendReq 走"专有剪贴板 plist + AppID 后台白名单",与
|
||||
// QQ / 抖音的"明文 URL Scheme"开放策略不同;为统一三方分享交互体验、
|
||||
// 减少 SDK 协议变更带来的不稳定,分享路径改走剪贴板 + 手动粘贴。
|
||||
// - 微信 OpenSDK 二进制依然保留(WeChatSDK.register / WeChatAuth.authorize
|
||||
// 用于登录),仅去掉分享 SDK 调用与 WeChatManager 中的分享桥接。
|
||||
// - scene 参数(friend / timeline)保留以满足 SharePlatform 协议契约,但
|
||||
// 剪贴板路径下用户进微信后自行决定粘贴到对话框还是朋友圈,文案统一不分。
|
||||
//
|
||||
// • share() — 对齐 daoqi msext `WechatShareManager.shareWithContent:`
|
||||
// (子游戏 gameController.m 活路径)2 type 分支:
|
||||
// - type == "2" → 截图分享(thumb = app icon)
|
||||
// - else → 链接分享(thumb = app icon)
|
||||
// 两条对外入口对应两个 ViewController 同名 handler 的差异(仅剪贴板内容分支不同):
|
||||
//
|
||||
// • shareLobby() — 对齐 daoqi msext `NewRootVC.m:471-534` 3 type 分支:
|
||||
// - type == "1" → 链接分享(thumb = app icon;原 msext
|
||||
// 用 sharelogo.png,按用户决定 fallback 到 app icon)
|
||||
// - type == "2" → 截图分享(thumb = 截图 40% 缩放)
|
||||
// - 其他 → 远端图分享(NSData 直发 + thumb = 远端图 40% 缩放)
|
||||
// • share() — 子游戏路径(gameController.m),2 type 分支:
|
||||
// - type == "2" → 截图:UIPasteboard.image
|
||||
// - else → 链接:UIPasteboard.string
|
||||
//
|
||||
// fire-and-forget:H5 端的 sharesuccess 已在 FriendsShareHandler 立即触发,
|
||||
// 此处不再回传结果(与原 dispatch_after 1s completion(YES) 实质等价)。
|
||||
// • shareLobby() — 大厅路径(NewRootVC.m),3 type 分支:
|
||||
// - type == "1" → 链接
|
||||
// - type == "2" → 截图
|
||||
// - 其他 → 远端图(下载 → UIPasteboard.image)
|
||||
//
|
||||
// fire-and-forget:H5 端 sharesuccess 已在 FriendsShareHandler 立即触发,
|
||||
// 此处不再回传结果。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
@MainActor
|
||||
public final class WechatShare: SharePlatform {
|
||||
|
||||
public static let shared = WechatShare()
|
||||
|
||||
private static let log = Logger(subsystem: "ylgamehall", category: "WechatShare")
|
||||
|
||||
private static let wechatScheme = "weixin://"
|
||||
|
||||
public let name = "WeChat"
|
||||
|
||||
public var isInstalled: Bool {
|
||||
WXApi.isWXAppInstalled()
|
||||
guard let url = URL(string: Self.wechatScheme) else { return false }
|
||||
return UIApplication.shared.canOpenURL(url)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - 子游戏路径(gameController.m)
|
||||
|
||||
public func share(_ content: ShareContent, scene: ShareScene) {
|
||||
guard WXApi.isWXAppInstalled() else { return }
|
||||
guard isInstalled else { return }
|
||||
|
||||
let wxScene: WeChatManager.ShareScene = (scene == .timeline) ? .timeline : .session
|
||||
let thumb = Self.appIconImage()
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
if content.type == "2" {
|
||||
// 截图分享(msext WechatShareManager isScreenshotShare 分支)
|
||||
let shot = try ImageProvider.captureScreenshot()
|
||||
guard let imageData = shot.jpegData(compressionQuality: 0.6) else { return }
|
||||
try await WeChatManager.shared.shareImage(
|
||||
.init(
|
||||
imageData: imageData,
|
||||
thumb: thumb,
|
||||
messageExt: content.desc,
|
||||
mediaTagName: "\(Self.appTagPrefix)游戏截图分享"
|
||||
),
|
||||
scene: wxScene
|
||||
)
|
||||
} else {
|
||||
// 链接分享(msext else 分支,含 type=="1" 与任何其它值)
|
||||
try await WeChatManager.shared.shareLink(
|
||||
.init(
|
||||
url: content.webpageUrl,
|
||||
title: content.title,
|
||||
desc: content.desc,
|
||||
thumb: thumb,
|
||||
mediaTagName: "\(Self.appTagPrefix)游戏分享"
|
||||
),
|
||||
scene: wxScene
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// SDK 回包失败不向 H5 反馈(sharesuccess 已乐观发出,对齐原项目)
|
||||
}
|
||||
if content.type == "2" {
|
||||
shareScreenshot()
|
||||
} else {
|
||||
shareLink(content)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 大厅路径(NewRootVC.m)
|
||||
|
||||
/// 大厅页 H5 调 friendsShare 的微信分享路径,对齐 msext NewRootVC.m
|
||||
/// :471-534 完整 3 分支结构。直发微信无 SharePanel。
|
||||
public func shareLobby(_ content: ShareContent, scene: ShareScene) {
|
||||
guard WXApi.isWXAppInstalled() else { return }
|
||||
guard isInstalled else { return }
|
||||
|
||||
let wxScene: WeChatManager.ShareScene = (scene == .timeline) ? .timeline : .session
|
||||
let appIcon = Self.appIconImage()
|
||||
switch content.type {
|
||||
case "1":
|
||||
shareLink(content)
|
||||
case "2":
|
||||
shareScreenshot()
|
||||
default:
|
||||
shareRemoteImage(content)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 截图分享
|
||||
|
||||
/// 截图 → UIPasteboard.image → 弹引导框(不自动拉起微信,等用户点"立即打开")。
|
||||
private func shareScreenshot() {
|
||||
do {
|
||||
let shot = try ImageProvider.captureScreenshot()
|
||||
UIPasteboard.general.image = shot
|
||||
Self.log.debug("WechatShare 截图已复制到剪贴板")
|
||||
} catch {
|
||||
Self.log.error("WechatShare 截图失败 \(error.localizedDescription, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
let message = """
|
||||
✅ 图片已复制到剪贴板
|
||||
|
||||
🎯 分享到好友、群聊或朋友圈步骤:
|
||||
1️⃣ 打开微信,选择好友、群聊或朋友圈
|
||||
2️⃣ 在输入框长按粘贴图片
|
||||
3️⃣ 点击发送即可分享
|
||||
"""
|
||||
showGuidanceAlert(title: "微信分享准备完成", message: message)
|
||||
}
|
||||
|
||||
// MARK: - 链接分享
|
||||
|
||||
/// title + desc + url 拼字符串 → UIPasteboard.string → 弹引导框。
|
||||
/// 三字段均空时用兜底文案"来自进贤聚友棋牌的精彩内容分享"(与 DouyinShare 同款策略)。
|
||||
private func shareLink(_ content: ShareContent) {
|
||||
var parts: [String] = []
|
||||
if !content.title.isEmpty { parts.append(content.title) }
|
||||
if !content.desc.isEmpty { parts.append(content.desc) }
|
||||
if !content.webpageUrl.isEmpty { parts.append(content.webpageUrl) }
|
||||
let shareText = parts.isEmpty
|
||||
? "来自进贤聚友棋牌的精彩内容分享"
|
||||
: parts.joined(separator: "\n")
|
||||
UIPasteboard.general.string = shareText
|
||||
Self.log.debug("WechatShare 链接已复制到剪贴板")
|
||||
|
||||
let message = """
|
||||
✅ 内容已复制到剪贴板
|
||||
|
||||
🎯 分享到好友、群聊或朋友圈步骤:
|
||||
1️⃣ 打开微信,选择好友、群聊或朋友圈
|
||||
2️⃣ 在输入框长按粘贴内容
|
||||
3️⃣ 点击发送即可分享
|
||||
"""
|
||||
showGuidanceAlert(title: "微信分享准备完成", message: message)
|
||||
}
|
||||
|
||||
// MARK: - 远端图分享(大厅 shareLobby 的"其他"分支)
|
||||
|
||||
/// 下载远端图 → UIPasteboard.image → 弹引导框。对齐 msext NewRootVC.m:526-534
|
||||
/// "公众号二维码分享"分支:原 SDK 路径直发 NSData,剪贴板路径写入解码后的
|
||||
/// UIImage 让用户在微信里长按粘贴。
|
||||
private func shareRemoteImage(_ content: ShareContent) {
|
||||
guard let url = URL(string: content.webpageUrl) else {
|
||||
Self.log.error("WechatShare 远端图 URL 解析失败")
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
do {
|
||||
switch content.type {
|
||||
case "1":
|
||||
// 链接分享:原 msext 用 sharelogo.png;用户决定 fallback 到 app icon
|
||||
try await WeChatManager.shared.shareLink(
|
||||
.init(
|
||||
url: content.webpageUrl,
|
||||
title: content.title,
|
||||
desc: content.desc,
|
||||
thumb: appIcon,
|
||||
mediaTagName: "\(Self.appTagPrefix)游戏下载链接"
|
||||
),
|
||||
scene: wxScene
|
||||
)
|
||||
case "2":
|
||||
// 截图分享:thumb = 截图 40% 缩放(对齐 msext imageByScalingAndCroppingForSize:0.4x)
|
||||
let shot = try ImageProvider.captureScreenshot()
|
||||
guard let imageData = shot.jpegData(compressionQuality: 0.6) else { return }
|
||||
try await WeChatManager.shared.shareImage(
|
||||
.init(
|
||||
imageData: imageData,
|
||||
thumb: ImageProvider.scaledThumb(shot, scale: 0.4),
|
||||
messageExt: content.desc,
|
||||
mediaTagName: "\(Self.appTagPrefix)游戏截图分享"
|
||||
),
|
||||
scene: wxScene
|
||||
)
|
||||
default:
|
||||
// 远端图分享(type == "" / "3" / 其他):下载 NSData 直发 +
|
||||
// thumb = 远端图 40% 缩放。messageExt/mediaTagName 都是固定
|
||||
// 字面(msext NewRootVC.m:530 字面,非 H5 desc)
|
||||
guard let url = URL(string: content.webpageUrl) else { return }
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
guard let image = UIImage(data: data) else { return }
|
||||
try await WeChatManager.shared.shareImage(
|
||||
.init(
|
||||
imageData: data,
|
||||
thumb: ImageProvider.scaledThumb(image, scale: 0.4),
|
||||
messageExt: "\(Self.appTagPrefix)公众号二维码分享",
|
||||
mediaTagName: "\(Self.appTagPrefix)公众号二维码分享"
|
||||
),
|
||||
scene: wxScene
|
||||
)
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
guard let image = UIImage(data: data) else {
|
||||
Self.log.error("WechatShare 远端图解码失败")
|
||||
return
|
||||
}
|
||||
UIPasteboard.general.image = image
|
||||
Self.log.debug("WechatShare 远端图已复制到剪贴板")
|
||||
} catch {
|
||||
// 与子游戏路径同样吞掉错误(sharesuccess 已乐观发出)
|
||||
Self.log.error("WechatShare 远端图下载失败 \(error.localizedDescription, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
let message = """
|
||||
✅ 图片已复制到剪贴板
|
||||
|
||||
🎯 分享到好友、群聊或朋友圈步骤:
|
||||
1️⃣ 打开微信,选择好友、群聊或朋友圈
|
||||
2️⃣ 在输入框长按粘贴图片
|
||||
3️⃣ 点击发送即可分享
|
||||
"""
|
||||
showGuidanceAlert(title: "微信分享准备完成", message: message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 共享常量 / 工具
|
||||
// MARK: - 引导弹窗
|
||||
|
||||
/// msext WechatShareManager 字面常量 "进贤聚友棋牌"(与 daoqi
|
||||
/// SGDefineInfo.h gamehallname 同源),用于 WXMediaMessage.mediaTagName 拼接。
|
||||
private static let appTagPrefix = "进贤聚友棋牌"
|
||||
/// 弹"立即打开微信 / 稍后分享"二选一 Alert,与 QQShare / DouyinShare 同款 UX。
|
||||
private func showGuidanceAlert(title: String, message: String) {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "立即打开微信", style: .default) { _ in
|
||||
if let url = URL(string: Self.wechatScheme),
|
||||
UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "稍后分享", style: .cancel))
|
||||
|
||||
/// app icon 缩略图。读 Info.plist `CFBundleIcons` → `CFBundlePrimaryIcon`
|
||||
/// → `CFBundleIconFiles` 的最后一个(最高分辨率),与原 msext
|
||||
/// WechatShareManager `getAppIconImage` 等价。失败兜底返回 1x1 透明图。
|
||||
private static func appIconImage() -> UIImage {
|
||||
if let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any],
|
||||
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
|
||||
let files = primary["CFBundleIconFiles"] as? [String],
|
||||
let last = files.last,
|
||||
let icon = UIImage(named: last) {
|
||||
return icon
|
||||
guard let topVC = Self.topViewController() else { return }
|
||||
topVC.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private static func topViewController() -> UIViewController? {
|
||||
let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first(where: { $0.activationState == .foregroundActive })
|
||||
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
||||
guard var top = scene?.windows.first(where: \.isKeyWindow)?.rootViewController
|
||||
?? scene?.windows.first?.rootViewController else { return nil }
|
||||
while let presented = top.presentedViewController { top = presented }
|
||||
if let nav = top as? UINavigationController, let visible = nav.visibleViewController {
|
||||
return visible
|
||||
}
|
||||
if let icon = UIImage(named: "AppIcon") { return icon }
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1))
|
||||
return renderer.image { _ in }
|
||||
return top
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user