From 113b4d496035cc02edf1ba3632ada4cca4e13551 Mon Sep 17 00:00:00 2001 From: joywayer Date: Tue, 30 Jun 2026 17:41:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=88=86=E4=BA=AB=E5=8E=BBsd?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 170 ++++++++++++++++++ docs/H5-Native-Contract.md | 21 ++- docs/SDK-Integration-Guide.md | 10 +- .../Bridge/Handlers/RemoteAudioHandler.swift | 27 +-- .../Source/SDK/WeChat/WeChatManager.swift | 144 +-------------- ylgamehall/Source/Share/SharePanel.swift | 2 +- ylgamehall/Source/Share/WechatShare.swift | 24 +-- 7 files changed, 227 insertions(+), 171 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..05a8303 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# ylgamehall + +进贤聚友棋牌 iOS 新外壳(greenfield 重写)。原生外壳 + 内置 H5(`gamehall.zip` 解压到沙盒)+ WebViewJavascriptBridge 桥接,企业签 / 超级签 / TF 渠道分发,**不上架 App Store**。 + +> 同仓库 `../daoqi/` 是已上线多年的旧外壳 `msext`(Objective-C / MRC / iOS 9.0+),作为本项目的"工作参照实现"。本项目按现代 iOS 标准重写,但 **H5 端零修改** 是不可妥协的第一准则。 + +--- + +## 技术栈 + +| 项 | 值 | +|---|---| +| 语言 | Swift 6.0(`SWIFT_APPROACHABLE_CONCURRENCY=YES`,`SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor`) | +| UI | SwiftUI + UIKit 互操作(按需) | +| 最低 iOS | 15.6 | +| 设备 | iPhone + iPad,仅横屏,`UIRequiresFullScreen=true` | +| Xcode | 26.5+(`objectVersion=77`,File System Synchronized Group) | +| 工程入口 | `ylgamehall.xcodeproj`(**无 CocoaPods**,不使用 workspace) | +| 依赖管理 | SPM(首选)+ Vendor `.xcframework`(闭源 SDK) | +| Bundle ID | `com.skyapp.ylgamehall` | +| 并发模型 | Swift async/await + actor 隔离(**不使用 Combine**) | + +### SPM 依赖 + +- `ZIPFoundation` — H5 zip 解压 +- `HappyDNS` — 七牛 SDK 配套 +- `Qiniu`(`qiniu/objc-sdk` v8.9.x) — 七牛对象存储 + +### Vendor 闭源 SDK + +放在 `Vendor//`,每个目录附 `README.md` 记录版本来源: + +- `Vendor/WechatSDK/` — 微信开放平台 +- `Vendor/AMap/` — 高德定位 +- `Vendor/OpenCoreAMR/` — AMR 编解码(`libopencore-amrnb.a` / `libopencore-amrwb.a`) + +--- + +## 目录结构 + +``` +ylgamehall/ +├── CLAUDE.md # AI 助手协作约定(第一权威) +├── README.md # 本文件 +├── ylgamehall.xcodeproj # Xcode 工程 +├── ylgamehall/ # App target +│ ├── AppDelegate.swift +│ ├── SceneDelegate.swift +│ ├── Info.plist +│ ├── ylgamehall-Bridging-Header.h +│ ├── Assets.xcassets +│ ├── Base.lproj/ # LaunchScreen.storyboard +│ ├── Resources/ +│ │ ├── gamehall.zip # 内置 H5 包 +│ │ ├── ChannelConfig.plist # 渠道配置(gameid / channel / agent / market 等) +│ │ ├── AppSecrets.plist # SDK 凭证(微信 AppID / 高德 Key / 七牛 等) +│ │ └── JS/ +│ │ └── WebViewJavascriptBridge.js +│ └── Source/ +│ ├── Audio/ # AMR 编解码、录音 / 播放、录音浮窗 +│ ├── Bridge/ # WebViewJavascriptBridge + 各 Handler +│ │ └── Handlers/ # 39 项 H5↔Native 桥接 handler +│ ├── Coordinator/ # AppCoordinator 启动流水线 +│ ├── Location/ # 高德定位封装 +│ ├── Login/ # 微信登录 +│ ├── Network/ # RemoteConfig、版本解析、七牛上传 +│ ├── Resource/ # 沙盒路径、H5 zip 升级、解压、子游戏下载 +│ ├── SDK/ # 微信 / 高德 SDK 包装 +│ ├── Share/ # 微信 / QQ / 抖音分享 + 分享面板 +│ └── WebView/ # WKWebView 容器、Splash、子游戏 VC +├── Vendor/ # 闭源 SDK +│ ├── WechatSDK/ +│ ├── AMap/ +│ └── OpenCoreAMR/ +└── docs/ # 项目文档(见下节) + └── res/ # 维护者私人素材池(项目代码不感知) +``` + +--- + +## 文档体系 + +所有架构决策、契约定义、执行计划、验收清单都在 `docs/` 下。**任何代码修改前先读对应文档**。 + +| 文档 | 角色 | 何时读 | +|------|------|--------| +| [`CLAUDE.md`](./CLAUDE.md) | 项目协作约定、契约边界声明、两条核心原则 | **每次对话先读** | +| [`docs/H5-Native-Contract.md`](./docs/H5-Native-Contract.md) | H5 ↔ 原生 桥接**契约**(黑盒可观察行为) | 涉及桥接接口必读 | +| [`docs/H5-Native-Implementation-Design.md`](./docs/H5-Native-Implementation-Design.md) | 新外壳实施蓝图(架构、模块、代码骨架) | 实现新外壳必读 | +| [`docs/Development-Plan.md`](./docs/Development-Plan.md) | 开发执行计划(Phase 0–10、依赖图、进度追踪) | 排期 / 进度更新必读 | +| [`docs/Verification-Checklist.md`](./docs/Verification-Checklist.md) | 功能验证清单(统一验收的操作手册) | 阶段验收时勾选 | +| [`docs/SDK-Integration-Guide.md`](./docs/SDK-Integration-Guide.md) | 微信 / 高德 / opencore-amr / 七牛 接入手册 | 接 SDK 时按章节落地 | +| [`docs/H5-Debug-Guide.md`](./docs/H5-Debug-Guide.md) | H5 端调试(Safari Web Inspector) | 调试 H5 console / Network / 断点时 | +| `../daoqi/` | 原项目 `msext` 工作参照实现 | 遇到行为差异 / 渠道注入 / SDK 时序等不确定问题时优先参考 | + +> 文档优先级冲突时:**Contract > Design > Plan**。 + +--- + +## 核心原则(两条,原则 A 是第一准则) + +### 原则 A:H5 端零修改(不可妥协) + +H5 端 **一行代码、一个字符、一个文件名、一个调用时机都不许改**。遇到契约边界冲突时,**回到原 msext 老路**、由原生 Swift 端硬扛,哪怕代码看起来"不现代"。 + +必须 1:1 与现网一致的五类内容: + +1. 桥接接口名(handler 字符串,含历史驼峰 / 下划线大小写) +2. 参数字段名(含 `"title "` 末尾空格、`Province` 大写 P 等历史包袱) +3. 参数数据结构(类型、嵌套、fallback) +4. H5 端注册方式(`WebViewJavascriptBridge` JS 协议 / 弹层 `window.settings.xxx`) +5. 空实现接口也必须保留注册(`opensaoma`、`getGameplay` 等) + +详见 [`docs/H5-Native-Contract.md`](./docs/H5-Native-Contract.md)。 + +### 原则 B:原生内部自由重构 + +原生内部一切自由——技术栈、模块结构、状态管理、SDK 选型、并发模型按现代 iOS 标准重新设计,不照搬旧项目实现细节。 + +**冲突时无条件选 A**:原生多一层适配 / 一段"看似过时"的代码可接受;契约破裂不可接受。 + +--- + +## 构建 + +### 前置 + +- macOS + Xcode 26.5+ +- 项目方提供的 SDK 凭证(按 [`docs/SDK-Integration-Guide.md`](./docs/SDK-Integration-Guide.md) 填到 `Resources/AppSecrets.plist`) +- Vendor 闭源 SDK 二进制(按 `Vendor//README.md` 放置) + +### 构建步骤 + +```bash +open ylgamehall.xcodeproj +``` + +Xcode → Product → Run(或 ⌘R)。 + +> **不要**新建 `Podfile` / `.xcworkspace`。本项目通过 SPM + Vendor 两层管理依赖,禁止引入 CocoaPods(ADR-006)。 + +### 渠道配置 + +`ylgamehall/Resources/ChannelConfig.plist` 是渠道注入入口(替代 msext 时代的"空目录名注入"机制): + +| key | 说明 | +|---|---| +| `gameid` | 游戏 ID | +| `channel` / `gamedir` | 渠道标识 / H5 解压子目录名 | +| `gamestart` | H5 启动入口目录名(默认 `gamehall`) | +| `gameconfig` | RemoteConfig 拉取地址 | +| `market` | 市场标识 | +| `agent` | 代理标识 | +| `appversion` | 当前外壳版本号(用于 RemoteConfig 升级判断) | + +--- + +## 提交与协作 + +- commit message 使用中文短句,描述"做了什么 + 为什么" +- 改动通过最小验证(BuildProject / 关键路径手测 / 单测)后可直接 commit,不必每次确认 +- 涉及桥接接口名 / 参数字段名 / 数据结构的改动必须在 commit message 中明示 **"契约影响"** 并附 [`docs/H5-Native-Contract.md`](./docs/H5-Native-Contract.md) 对应章节 +- 每完成一个 Phase 子项立即同步勾选 [`docs/Development-Plan.md`](./docs/Development-Plan.md) §5 / §8 +- 不在未授权情况下执行 `git push --force` / `git reset --hard` / `git rebase` 等不可逆操作 + +--- + +## 验收 + +新外壳完成后,**在不修改 H5 任何一行代码** 的前提下,逐项跑 [`docs/H5-Native-Contract.md`](./docs/H5-Native-Contract.md) §10 的 26 项验收清单。任意一项 H5 表现与现网(`../daoqi/msext`)不一致即视为契约违反。 diff --git a/docs/H5-Native-Contract.md b/docs/H5-Native-Contract.md index 1f8e7d1..b9b5533 100644 --- a/docs/H5-Native-Contract.md +++ b/docs/H5-Native-Contract.md @@ -379,9 +379,9 @@ window.rootViewController = NavgationController **流程**: 1. 下载 AMR 到本地 2. `VoiceConverter ConvertAmrToWav` 转 WAV -3. `AVAudioPlayer` 播放 +3. `AVAudioPlayer` 播放(若当前已有语音在播,先停旧、给 H5 发 `gameui_stop_voice(被打断 user)` 再起新播放) 4. 开始播放时 callback H5:`gameui_play_voice(user)` (见 §3.2 [7]) -5. 播放结束 callback H5:`gameui_stop_voice(user)` (见 §3.2 [8]) +5. 播放结束 / 被打断 callback H5:`gameui_stop_voice(user)` (见 §3.2 [8]) ##### 【6】 `voicePlaying` — 语音播放总开关 @@ -563,7 +563,7 @@ window.rootViewController = NavgationController | 5 | `shakeEnd` | motionEnded 摇一摇结束 | `nil` | ✓ | ✓ | | 6 | `getlocationinfo` | 定位成功/失败 | 见下方表 B | ✓ | ✓ | | 7 | `gameui_play_voice` | mediaTypeAudio 开始播放(且 voicePlaying=1 时)| 字符串 user_id | ✓ | ✓ | -| 8 | `gameui_stop_voice` | mediaTypeAudio AVAudioPlayer 播放结束 | 字符串 user_id(最近一次 mediaTypeAudio 的 user 字段)| ✓ | ✓ | +| 8 | `gameui_stop_voice` | mediaTypeAudio AVAudioPlayer 播放结束 / 被新一次 mediaTypeAudio 打断 / 解码失败 | 字符串 user_id(**该次结束 / 被打断的语音**所绑定的 user,不是入参里最新的 user — 与 daoqi 旧实现语义不同,详见下方 `gameui_stop_voice` 行为说明)| ✓ | ✓ | | 9 | `getaudiourl` | 录音 + 上传成功(七牛或旧 PostFile)| `{ "audiourl": "<七牛 URL>", "time": "<秒数字符串>" }` | ✓ | ✓ | | 10 | `sharelogin` | 微信授权 OAuth 拿到 userinfo 后 | 见下方表 C | ✓ | ✓ | | 11 | `sharesuccess` | 分享完成(微信 SendMessageToWXResp / 闲聊 finishBlock)| `{ "success": "2", "type": "" }` | ✓ | ✓ | @@ -586,6 +586,21 @@ H5 端 N 年来收到的实际语义就是:**`"2"` = 进入后台、`"1"` = (顺带:原 msext 还有一条 iOS<9 UIWebView 路径 `RootVC.m:516-543` / `fourviewVC.m:1254-1282`,发的值反过来:进入后台 → `"1"` / 回到前台 → `"2"`。该路径在新外壳最低 iOS 15.6 下完全不可达,**不能作为对齐依据**,请忽略。) +**`gameui_stop_voice` 行为说明**(与 daoqi 旧实现的有意差异,用户反馈驱动): + +daoqi `NewRootVC.m:1736-1748` / `gameController.m:1288-1308` 把 `voicePlayer` / `self.User_id` 都作为 VC 成员变量。覆盖式播放(前一条还在播,第二条到来)的代码路径: +1. `self.User_id = userid;` 先覆盖最新 user +2. `voicePlayer = [[AVAudioPlayer alloc]...]` 直接换 ivar(MRC 下旧 player 实际泄漏继续在播) +3. 旧 player 自然播完触发 `audioPlayerDidFinishPlaying:` → 调 `stopradio` → `callHandler:@"gameui_stop_voice" data:self.User_id` + +结果:旧消息对应的 `gameui_stop_voice` 发出去的 user_id **实际是新消息的 user_id**;H5 收不到旧消息的停止动画信号,旧动画一直转,新消息那条反而停了。 + +新外壳实现(`VoicePlayer.swift`)把 user 与 player 一一绑定(`currentUser`): +- 覆盖式 play 时**主动**停旧 player,并用旧 player 绑定的 user 触发一次 `gameui_stop_voice`,再起新播放 + `gameui_play_voice(新 user)` +- 自然结束 / 解码失败:用结束那个 player 绑定的 user 发 `gameui_stop_voice`,不再读"最近一次入参" + +H5 端无需任何改动:仍然按照"收到 `gameui_stop_voice(user)` 就停这个 user 的动画"处理即可,只是信号现在会更准。验证场景:H5 连发两条 `mediaTypeAudio`(user A → user B,A 还没播完 B 就来),现在 H5 会先收 `gameui_stop_voice(A)` 再收 `gameui_play_voice(B)`,B 播完再收 `gameui_stop_voice(B)`。daoqi 旧路径下 A 那次停止信号永远收不到。 + **表 A — `getphoneinfo`**: ```json { diff --git a/docs/SDK-Integration-Guide.md b/docs/SDK-Integration-Guide.md index 6bfa1ba..929e1cd 100644 --- a/docs/SDK-Integration-Guide.md +++ b/docs/SDK-Integration-Guide.md @@ -78,7 +78,7 @@ | `AppDelegate.swift` | `AMapWrapper.bootstrap()` → 真实调隐私合规 3 步 + apiKey | | `SceneDelegate.swift` | `openURLContexts` → 真实调 `WXApi.handleOpen(url, delegate:)` | | `AccreditLoginHandler` | `accreditlogin` → 真实拉起微信授权 → 反向 callback `sharelogin` 7 字段(Province 大写 P) | -| `WechatShare` | `share()` → 真实调 `WXApi.send(SendMessageToWXReq)` | +| `WechatShare` | `share()` / `shareLobby()` → 剪贴板 + 引导框 + `weixin://` 跳转(不依赖 SDK 的分享 API,与 QQ / 抖音同款交互) | | `StartLocationHandler` | `startlocation` → 真实调 `AMapLocationManager.requestLocation` → 反向 callback `getlocationinfo` 9 字段(latitude/longitude string, province 小写 p) | | `BackGameDataHandler` | 子游戏 backgameData 退出时自动清 `WXApi.delegate = nil` + `LocationService.stop()` | @@ -236,10 +236,10 @@ bridge.register("prepareaudio") { _, callback in |---|---|---| | `Vendor/WechatSDK/WechatOpenSDK.xcframework` | ⏳ 等用户放置 | 项目方从腾讯开放平台下 `OpenSDK2.0.5+_NoPay.zip` 解压 → 放进 `Vendor/WechatSDK/` → 按 §A.1 Embed & Sign | | `Source/SDK/WeChat/WeChatSDK.swift` | ✅ 已写 | `register()` + `handleOpenURL` 包 canImport | -| `Source/SDK/WeChat/WeChatManager.swift` | ✅ 已写 | authorize / shareLink async wrapper + WXApiDelegate | +| `Source/SDK/WeChat/WeChatManager.swift` | ✅ 已写 | authorize async wrapper + WXApiDelegate(分享路径已剥离,走剪贴板见 `WechatShare`) | | `Source/Login/WeChatAuth.swift` | ✅ 已写 | OAuth2 客户端直拼 sns/oauth2 + userinfo + 7 字段 | | `Source/Bridge/Handlers/AccreditLoginHandler.swift` | ✅ 已升级 | 真实拉起授权 + sharelogin 反向 callback(Province 大写 P) | -| `Source/Share/WechatShare.swift` | ✅ 已升级 | 真实链接分享(type=2/3 截图待 Phase 4.F) | +| `Source/Share/WechatShare.swift` | ✅ 已改造 | 剪贴板 + 引导框 + `weixin://` 跳转(与 QQ / 抖音一致,不依赖微信 SDK 分享 API) | | `Source/Bridge/Handlers/BackGameDataHandler.swift` | ✅ 已加钩子 | `WXApi.delegate = nil` 子游戏 pop 时清理 | | `AppDelegate.swift` | ✅ 已加注册 | `WeChatSDK.register()` | | `SceneDelegate.swift` | ✅ 已加 openURL | `WeChatSDK.handleOpenURL(ctx.url)` | @@ -248,8 +248,8 @@ bridge.register("prepareaudio") { _, callback in ### 1.4 验收(用户 Xcode 加完 framework 后跑) - [ ] H5 调 `bridge.callHandler('accreditlogin')` → 拉起微信 → 同意授权 → 大厅 H5 收 `sharelogin({openid,...,Province})`,**Province 大写 P** -- [ ] H5 调 `bridge.callHandler('friendsSharetypeUrlToptitleDescript', {sharefriend:"2", ...})` → 拉起微信朋友圈 -- [ ] H5 调 `sharefriend:"1"` → SharePanel 三按钮,点微信 → 拉起微信好友选择 +- [ ] H5 调 `bridge.callHandler('friendsSharetypeUrlToptitleDescript', {sharefriend:"2", ...})` → 写剪贴板 + 弹引导框 → 点"立即打开微信"跳转到微信首页(用户自行粘贴到朋友圈) +- [ ] H5 调 `sharefriend:"1"` → SharePanel 三按钮,点微信 → 写剪贴板 + 弹引导框 → 跳微信首页(用户自行粘贴到对话框) - [ ] **关键回归**:截图分享 / QQ 分享 / 抖音分享路径不受影响 ### 1.5 风险 diff --git a/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift b/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift index 3460d4f..cf81462 100644 --- a/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift +++ b/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift @@ -261,12 +261,11 @@ public enum RemoteAudioHandler { return // 参数不全 → 不触发 cb(对齐 daoqi 入参空时走到 file 不存在 return) } - // 2. 记录 lastUserId(无论是否实际播放都更新,对齐 daoqi - // `self.User_id = userid;` 总是执行的行为) - VoicePlayer.shared.recordIncomingUser(user) - - // 3. 注册 VoicePlayer.onFinish(每次 mediaTypeAudio 更新,绑定当前 bridge; - // [weak bridge] 让 bridge 释放后 onFinish 自动失效) + // 2. 注册 VoicePlayer.onFinish(每次 mediaTypeAudio 更新,绑定当前 bridge; + // [weak bridge] 让 bridge 释放后 onFinish 自动失效)。 + // onFinish 触发时机:自然结束 / 解码失败 / 被新一次 play 打断; + // payload `finishedUser` 是被结束 / 被打断那条语音绑定的 user + // (由 VoicePlayer 内部 currentUser 跟踪),不是入参里最新的 user。 VoicePlayer.shared.onFinish = { [weak bridge] finishedUser in Task { @MainActor in bridge?.call( @@ -277,7 +276,7 @@ public enum RemoteAudioHandler { } } - // 4. 异步下载 AMR + // 3. 异步下载 AMR let amrData: Data do { let (d, _) = try await URLSession.shared.data(from: downloadURL) @@ -288,7 +287,7 @@ public enum RemoteAudioHandler { // `if (![[NSFileManager defaultManager] fileExistsAtPath:filename]) return;`) } - // 5. 写到沙盒 .amr 临时文件 + // 4. 写到沙盒 .amr 临时文件 let tempName = VoiceRecorder.currentTimeString() let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let amrLocal = docs.appendingPathComponent(tempName).appendingPathExtension("amr") @@ -300,7 +299,7 @@ public enum RemoteAudioHandler { return } - // 6. AMR → WAV 转码 + // 5. AMR → WAV 转码 // daoqi 文件名约定:tempName + "_AmrToWav.wav"(NewRootVC.m:341) let wavLocal = docs .appendingPathComponent("\(tempName)_AmrToWav") @@ -317,14 +316,16 @@ public enum RemoteAudioHandler { transcoded = false } - // 7. voicePlaying 总开关 + 实际播放 + gameui_play_voice + // 6. voicePlaying 总开关 + 实际播放 + gameui_play_voice // (仅转码成功 + 开关 enabled 时触发,对齐 daoqi - // NewRootVC.m:343-353 if (canbofang) { play + callHandler }) + // NewRootVC.m:343-353 if (canbofang) { play + callHandler })。 + // 若当前已有正在播放的 player,VoicePlayer.play 会先停旧 player 并 + // 通过 onFinish 反向调 gameui_stop_voice 让 H5 停旧动画,再起新播放。 let canPlay = await VoiceCenter.shared.isEnabled log.debug("mediaTypeAudio user=\(user, privacy: .public) transcoded=\(transcoded, privacy: .public) canPlay=\(canPlay, privacy: .public)") if transcoded, canPlay { do { - try VoicePlayer.shared.play(wavLocal) + try VoicePlayer.shared.play(wavLocal, user: user) // gameui_play_voice 数据是 user 字符串(非 dict) bridge.call( "gameui_play_voice", @@ -337,7 +338,7 @@ public enum RemoteAudioHandler { } } - // 8. 触发 mediaTypeAudio cb(对齐 daoqi NewRootVC.m:357,**总是**触发 + // 7. 触发 mediaTypeAudio cb(对齐 daoqi NewRootVC.m:357,**总是**触发 // 即使转码失败 / canbofang=NO 都触发;仅下载失败的 return 路径不触发) callback?(.string("Response from mediaTypeAudio")) } diff --git a/ylgamehall/Source/SDK/WeChat/WeChatManager.swift b/ylgamehall/Source/SDK/WeChat/WeChatManager.swift index f1ae88c..436e200 100644 --- a/ylgamehall/Source/SDK/WeChat/WeChatManager.swift +++ b/ylgamehall/Source/SDK/WeChat/WeChatManager.swift @@ -2,10 +2,11 @@ // WeChatManager.swift // ylgamehall // -// 微信 SDK 持久 delegate + authorize + share 异步包装。 -// 把 WXApiDelegate 的回调 → async/await,state UUID 配对多发起方。 +// 微信 SDK 持久 delegate + authorize 异步包装。 +// 把 WXApiDelegate 的 onResp 回调 → async/await,state UUID 配对多发起方。 // -// Design §8.5;msext WXApiManager.m 等价。 +// 注:分享路径已改走剪贴板 + 引导框(详见 WechatShare.swift),不再依赖 +// 微信 SDK 的 SendMessageToWXReq;本类只剩登录 OAuth 链路。 // import Foundation @@ -23,8 +24,6 @@ public final class WeChatManager: NSObject { /// 待 resolve 的 authorize continuation(state UUID 配对) private var authPending: [String: CheckedContinuation] = [:] - /// 待 resolve 的 share continuation(FIFO 串行 — 微信回包不带配对 ID) - private var sharePending: [CheckedContinuation] = [] public override init() { super.init() @@ -84,125 +83,6 @@ public final class WeChatManager: NSObject { return top } - // MARK: - Share - - public enum ShareScene { - case session // 微信好友 - case timeline // 朋友圈 - } - - /// 链接分享载荷。`mediaTagName` 对齐 msext WXApiRequestHandler `TagName:` - /// 参数(写入 WXMediaMessage.mediaTagName,微信内部分类用)。 - /// 含 UIImage 故无法 Sendable —— 实际使用都在 MainActor 上发起,无跨 actor 风险。 - public struct ShareLink { - public let url: String - public let title: String - public let desc: String - public let thumb: UIImage? - public let mediaTagName: String? - - public init( - url: String, title: String, desc: String, - thumb: UIImage?, mediaTagName: String? = nil - ) { - self.url = url; self.title = title; self.desc = desc - self.thumb = thumb; self.mediaTagName = mediaTagName - } - } - - /// 图片分享载荷(type=2 截图)。 - /// - `imageData`:大图原始字节(JPEG) - /// - `thumb`:缩略图源 — SDK `setThumbImage:` 内部压到 32KB - /// - `messageExt`:H5 的 description;写入 `WXMediaMessage.description` - /// 与 `.messageExt`,对齐 msext `WXApiRequestHandler sendImageData:MessageExt:` - /// - `mediaTagName`:对齐 msext `TagName:` 参数 - public struct ShareImage { - public let imageData: Data - public let thumb: UIImage - public let messageExt: String - public let mediaTagName: String? - - public init( - imageData: Data, thumb: UIImage, - messageExt: String = "", mediaTagName: String? = nil - ) { - self.imageData = imageData - self.thumb = thumb - self.messageExt = messageExt - self.mediaTagName = mediaTagName - } - } - - /// 链接分享(FIFO 串行)。微信回包不携带配对 ID,连续调时按入队顺序消费。 - public func shareLink(_ link: ShareLink, scene: ShareScene) async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) 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 - if let mediaTagName = link.mediaTagName { - message.mediaTagName = mediaTagName - } - if let thumb = link.thumb { - message.setThumbImage(thumb) - } - - 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) - } - // 1.x: sendReq BOOL 返回;分享回包通过 SceneDelegate.openURLContexts → - // WXApi.handleOpen(url:delegate:WeChatManager.shared) → onResp 路径到达。 - // ObjC selector +sendReq:,Swift 简化为 send(_:)(去 Req 后缀)。 - // 运行时仍调 +[WXApi sendReq:] - WXApi.send(req) - } - } - - /// 图片分享(FIFO 串行,复用 sharePending)。对齐 msext WechatShareManager - /// `sendImageData:TagName:MessageExt:Action:ThumbImage:InScene:` 的字段映射: - /// TagName → mediaTagName - /// MessageExt → description(接收方预览可见)+ messageExt(SDK 内部回包) - /// ThumbImage → setThumbImage - public func shareImage(_ img: ShareImage, scene: ShareScene) async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - sharePending.append(cont) - - let media = WXImageObject() - media.imageData = img.imageData - - let message = WXMediaMessage() - message.description = img.messageExt - message.mediaObject = media - message.setThumbImage(img.thumb) - if !img.messageExt.isEmpty { - message.messageExt = img.messageExt - } - if let mediaTagName = img.mediaTagName { - message.mediaTagName = mediaTagName - } - - 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) - } - // ObjC selector +sendReq:,Swift 简化为 send(_:)(去 Req 后缀)。 - // 运行时仍调 +[WXApi sendReq:] - WXApi.send(req) - } - } - // MARK: - Internal: resolve callbacks fileprivate func resolveAuth(state: String, code: String?, errCode: Int) { @@ -213,16 +93,6 @@ public final class WeChatManager: NSObject { 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 @@ -242,11 +112,8 @@ extension WeChatManager: WXApiDelegate { 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) - } } + // 分享走剪贴板路径(WechatShare),不再有 SendMessageToWXResp 回包 } } @@ -260,5 +127,4 @@ public struct WXAuthCodePayload: Sendable { public enum WeChatError: Error, Sendable { case sdkNotLinked case authFailed(Int) - case shareFailed(Int) } diff --git a/ylgamehall/Source/Share/SharePanel.swift b/ylgamehall/Source/Share/SharePanel.swift index 288d038..18f8b33 100644 --- a/ylgamehall/Source/Share/SharePanel.swift +++ b/ylgamehall/Source/Share/SharePanel.swift @@ -22,7 +22,7 @@ public final class SharePanel: UIView { private static let log = Logger(subsystem: "ylgamehall", category: "SharePanel") - private static let panelHeight: CGFloat = 200 + private static let panelHeight: CGFloat = 150 private static let buttonSize: CGFloat = 60 private static let animationDuration: TimeInterval = 0.25 diff --git a/ylgamehall/Source/Share/WechatShare.swift b/ylgamehall/Source/Share/WechatShare.swift index f21a1d1..cbc95e5 100644 --- a/ylgamehall/Source/Share/WechatShare.swift +++ b/ylgamehall/Source/Share/WechatShare.swift @@ -104,16 +104,20 @@ public final class WechatShare: SharePlatform { // MARK: - 链接分享 - /// title + desc + url 拼字符串 → UIPasteboard.string → 弹引导框。 - /// 三字段均空时用兜底文案"来自进贤聚友棋牌的精彩内容分享"(与 DouyinShare 同款策略)。 + /// title + "\n\n" + desc 拼字符串 → UIPasteboard.string → 弹引导框。 + /// 与 DouyinShare 文本分享 1:1 同款(不含 url,避免在微信对话框出现裸链接), + /// 双侧都空时用兜底文案"来自进贤聚友棋牌的精彩内容分享"。 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") + var shareText = "" + if !content.title.isEmpty { + shareText = content.title + } + if !content.desc.isEmpty { + shareText = shareText.isEmpty ? content.desc : "\(shareText)\n\n\(content.desc)" + } + if shareText.isEmpty { + shareText = "来自进贤聚友棋牌的精彩内容分享" + } UIPasteboard.general.string = shareText Self.log.debug("WechatShare 链接已复制到剪贴板") @@ -121,7 +125,7 @@ public final class WechatShare: SharePlatform { ✅ 内容已复制到剪贴板 🎯 分享到好友、群聊或朋友圈步骤: - 1️⃣ 打开微信,选择好友、群聊或朋友圈 + 1️⃣ 打开微信,选择好友、群聊或朋友圈 2️⃣ 在输入框长按粘贴内容 3️⃣ 点击发送即可分享 """