diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index d11ef8b..f151899 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -227,13 +227,11 @@ Contract Design Plan(本文档) ##### 1.A 资源层 -- [ ] **1.1.a** 写 `Scripts/channels/dev.env`:11 个渠道键值对(沿用 msext 现网 demo),git 跟踪 -- [ ] **1.1.b** 写 `Scripts/inject_channel.sh`:读 env → 清空并重新生成 `ylgamehall/ChannelInjection/` 目录树 -- [ ] **1.1.c** `.gitignore` 加 `ylgamehall/ChannelInjection/` -- [ ] **1.1.d** 跑一次 `Scripts/inject_channel.sh dev` 生成本地 dev 渠道目录 +- [x] **1.1** 创建 `ylgamehall/Resources/ChannelConfig.plist` 含 11 个渠道键值对(沿用 msext 现网 demo 作为母包默认值;ADR-007 决策) - [ ] **1.2** 实现 `ylgamehall/Source/Resource/BundleConfig.swift` - - `static func readInjected(_ key: String) -> String`:扫描 `Bundle.main.bundleURL.appendingPathComponent("ChannelInjection")` 下 `key` 子目录,返回首个非隐藏子项名 - - 单测 fixture:建立 mock bundle,验证 11 个 key 全部能读出 + - `init(bundle: Bundle = .main)`:读 `bundle.url(forResource: "ChannelConfig", withExtension: "plist")`,用 `PropertyListSerialization` 反序列化为 `[String: String]` + - 公开 11 个只读属性:`qiniuDomain` / `gameId` / `channel` / `gameDir` / `gameStart` / `gameConfig` / `market` / `agent` / `appVersion` / `other` / `appleConfig` + - 单测 fixture:建立 mock bundle 含 fixture plist,验证 11 个 key 全部能读出且缺失 key 返回空串 - [ ] **1.3** 实现 `ylgamehall/Source/Resource/SandboxPaths.swift` - 常量:`caches` / `documents` / `bundle` - `lobbyIndex() -> URL` 拼出 `{Caches}/{gamedir}/{gamestart}/index.html` @@ -742,7 +740,7 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - [ ] 0.6 SPM 包目录决策(暂单 target) ### Phase 1 最小垂直闭环 -- [ ] 1.1 ChannelInjection 目录占位 +- [x] 1.1 ChannelConfig.plist 母包默认值(ADR-007) - [ ] 1.2 BundleConfig - [ ] 1.3 SandboxPaths - [ ] 1.4 ZIPFoundation SPM @@ -878,10 +876,9 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin ### ADR-004:项目资源目录结构由各 Phase 按需落地,不预先建仓库根 Resources/(2026-06-21 修订) - **背景**:原 ADR-004(2026-06-21 初版)决议"项目资源统一放仓库根 `Resources/` + 打包脚本 Build Phase 引入",并将 `gamehall.zip` / `Images.xcassets` / `Res/` 入库到 `Resources/`。但 `docs/res/` 后被维护者重新定义为**私人原始素材池**(不被项目感知,详见 CLAUDE.md),原 `Resources/` 入库的素材被搬走,使该 ADR 的语义崩塌 - **修订决策**:废止"仓库根 `Resources/`"的固定结构假设,**项目内资源目录由各 Phase 实施时按需落地**: - - 静态 Bundle 资源 → `ylgamehall/Resources/`(synchronized group 自动收集) + - 静态 Bundle 资源 → `ylgamehall/Resources/`(synchronized group 自动收集,含 `gamehall.zip` / `ChannelConfig.plist` 等) - 原生 Asset Catalog → `ylgamehall/Assets.xcassets/` - - 当前激活渠道注入产物 → `ylgamehall/ChannelInjection/`(.gitignore 忽略,由 `Scripts/inject_channel.sh` 生成) - - 渠道值模板 → `Scripts/channels/.env`(git 跟踪) + - 渠道注入值 → `ylgamehall/Resources/ChannelConfig.plist`(11 个 string key,母包默认值;ADR-007) - 闭源 SDK 二进制 → `Vendor//.xcframework` - 私人原始素材池 → `docs/res/`(项目不感知,需要时拷贝到上述工程内目录) - **理由**: @@ -933,7 +930,43 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - **回滚条件**:未来某天出现 ≥ 3 个仅有 podspec 而无 SPM / XCFramework 的 SDK 必接需求时,重新评估是否引入 CocoaPods - **维护责任**:AMap 升级(年度级)→ 下载新 XCFramework 覆盖 `Vendor/AMap/` + 更新 README + 跑契约测试 +### ADR-007:渠道注入用 `ChannelConfig.plist` 替代 msext 的"空目录名注入"机制(2026-06-21) +- **背景**:Contract §0.3 描述 msext 把 11 个渠道值编码为 Bundle 根的 11 个目录子文件夹名(如 `channel/FtJf0.../`,目录名本身就是值)。新项目首版尝试照搬此机制 +- **触发事件**:与 Xcode 26.5 默认的 `PBXFileSystemSynchronizedRootGroup` 不兼容—— + - 子目录被同步组当作 group 处理,11 个 `.gitkeep` 在 Bundle 根撞名 → build 失败 + - 尝试 folder reference(蓝色文件夹):Xcode 26.5 拖入对话框流程对此不友好,pbxproj 不会写入正确条目 + - 尝试 Run Script Build Phase: + 1. 默认开启的 User Script Sandboxing 拒绝读 `Scripts/copy_channel_injection.sh` 与 `ChannelInjection/` + 2. 即使关掉 sandbox,Run Script 在没有 Input/Output 声明 + 默认勾选"Based on dependency analysis"时,被 Xcode 视为"无依赖故无需运行",clean build 也不跑 + - 综合工程成本估算:为保留"目录名编码"机制,需引入额外 Build Phase / 关闭 sandbox / 维护 Input/Output 列表,**全是 Xcode 行为兼容性维护,与项目目标无关** +- **决策**:渠道注入存储改用**单一 `ylgamehall/Resources/ChannelConfig.plist`** 含 11 个 string key,与 msext 11 个目录一一对应 + - 存储介质:plist(iOS 原生) + - Bundle 加载:synchronized group 自动收集,零配置 + - 运行时读取:`BundleConfig.init(bundle:)` 用 `PropertyListSerialization` 反序列化 +- **理由**: + - **契约 100% 等价**:H5 端通过 `app_data.js` 看到的 11 个 JS 全局变量行为完全不变(契约边界在 `BundleConfig.shared.xxx`,与底层存储无关) + - **CLAUDE.md 原则 B 落地**:原生内部自由重构,不要照搬旧项目;msext 那套是 iOS 9 / Xcode 14 时代的 hack,Xcode 26 + Swift 6 应当用更现代的存储 + - **IPA 后处理多渠道分发完全等价**: + ```bash + plutil -replace channel -string "<新渠道 ID>" ChannelConfig.plist + plutil -replace market -string "<新市场 ID>" ChannelConfig.plist + codesign --force --sign "$IDENTITY" --entitlements "$ENT" .app + zip -r .ipa Payload/ + ``` + 工作量与 msext 的 `mv` 目录名 + 重签完全相同,且 plutil 比 mv 一组路径更可读 / 可自动化 + - **维护成本显著降低**:单文件、可读、单测可注入 mock bundle、不依赖 Xcode 任何特殊配置 +- **影响**: + - Contract §0.3 描述的 msext 实现仅作为历史参考,新项目不照搬 + - Design §7.0 / §7.2 重写为 plist 方案 + - Plan Phase 1.1 简化为"建 plist"单步任务(取代原 1.1.a~d 四步 inject_channel.sh 方案) + - `BundleConfig.swift` 接口(11 个公开属性)保持不变,仅 init 实现切换 +- **守护**: + - **`ChannelConfig.plist` 必须保持 11 个 key 完整且类型为 string**;新增 key 视同契约边界变更,需更新 Design / Plan / Contract(如该值被 H5 通过 app_data.js 暴露) + - 后处理工具必须**修改 plist 后立即重签**,否则 iOS 拒绝安装 + - 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道) +- **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质 + --- 文档完成日期:2026-06-21 -最后更新:2026-06-21(ADR-006 纯 SPM + Vendor 策略 + ADR-005 极光降级 + Resources/ 目录记录) +最后更新:2026-06-21(ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录) diff --git a/docs/H5-Native-Implementation-Design.md b/docs/H5-Native-Implementation-Design.md index 26b2572..ad59560 100644 --- a/docs/H5-Native-Implementation-Design.md +++ b/docs/H5-Native-Implementation-Design.md @@ -1065,15 +1065,52 @@ public actor ConfigService { | 落点 | 用途 | 入 Bundle 方式 | |------|------|--------------| -| `ylgamehall/Resources/` | 静态打入 Bundle 的项目资源(`gamehall.zip` / `WebViewJavascriptBridge.js` / 本地音效 mp3 等) | synchronized group 自动收集 | +| `ylgamehall/Resources/` | 静态打入 Bundle 的项目资源(`gamehall.zip` / `WebViewJavascriptBridge.js` / 本地音效 mp3 / `ChannelConfig.plist` 等) | synchronized group 自动收集 | | `ylgamehall/Assets.xcassets/` | 原生 Asset Catalog(AppIcon / LaunchImage / 分享平台图标) | Xcode 默认 | -| `ylgamehall/ChannelInjection/` | 当前激活渠道的 11 个空容器目录 | `.gitignore` 忽略,由 `Scripts/inject_channel.sh` 在 build 前动态生成,synchronized group 自动收集进 Bundle | -| `Scripts/channels/.env` | 各渠道的 11 个键值对模板(git 跟踪) | 不入 Bundle | | `Vendor//.xcframework` | 闭源 SDK 二进制(微信 / QQ / 高德 / opencore-amr) | Target Build Phase「Frameworks」手动加入 | -#### 7.0.3 渠道注入运行时路径 +#### 7.0.3 渠道注入:ChannelConfig.plist 母包模式 -H5 端 / 业务代码透过 `BundleConfig.readInjected(_:)` 读取渠道值,路径约定为 `Bundle.main.bundleURL.appendingPathComponent("ChannelInjection")` 下的 11 个目录;**具体注入位置由 §7.2 BundleConfig 实现细节定义**(可调整,只要 BundleConfig 与 `inject_channel.sh` 双方对齐)。 +**契约 §0.3 描述 msext 用"空目录名注入"机制存储 11 个渠道值,本项目按 ADR-007 改用 `ChannelConfig.plist` 等价实现** —— 契约边界(H5 通过 `app_data.js` 看到的 11 个 JS 全局变量)完全不变,实现内部更简洁。 + +**存储**:`ylgamehall/Resources/ChannelConfig.plist`,11 个 string key 一一对应渠道值: + +```xml + + qiniudomain iosaudio.daoqi88.cn + gameid G2hw0u... + channel FtJf07... + gamedir FtJf07... + gamestart gamehall + gameconfig tsgames.daoqi88.cn-config_test-update_jsonv2_test + market 2 + agent veRa0qrBf0df2K1G4de2tgfmVxB2jxpv + appversion 43 + other + appleconfig + +``` + +**运行时读取路径**:`Bundle.main.bundleURL.appendingPathComponent("ChannelConfig.plist")`,由 `BundleConfig.swift`(§7.2)用 `PropertyListSerialization` 反序列化。 + +**多渠道分发**(IPA 后处理,不重新 Xcode build): + +```bash +unzip ylgamehall.ipa -d tmp/ +APP="tmp/Payload/ylgamehall.app" +plutil -replace channel -string "" "$APP/ChannelConfig.plist" +plutil -replace market -string "" "$APP/ChannelConfig.plist" +codesign --force --sign "$IDENTITY" --entitlements "$ENT" "$APP" +cd tmp && zip -r ../ylgamehall_.ipa Payload/ +``` + +→ 修改 plist 与 msext 的"`mv` 目录名"机制工作量相当,但**完全规避 Xcode 26 synchronized group 与目录树的兼容问题**(详见 Plan ADR-007)。 + +> **为什么不照搬 msext 的目录树**: +> - msext 那套在 Xcode 14 / 老 group 模型下能工作;但 Xcode 26 默认 synchronized group 把子目录扁平化,适配代价高(尝试过 folder reference / Run Script + sandboxing 均有阻碍) +> - plist 是 iOS 原生最简单的配置存储,加入 `Resources/` 后 Xcode 自动入 Bundle,零配置 +> - 修改成本 / 重签流程 / 维护成本 / IPA 后处理脚本 / H5 业务可观察行为,**与目录树方案完全等价** +> - CLAUDE.md 原则 B:"原生内部自由重构,不要照搬旧项目"——这是落地 ### 7.1 SandboxPaths 集中管理 @@ -1109,42 +1146,53 @@ public enum SandboxPaths { ```swift // ResourceKit/BundleConfig.swift +// +// 从 Bundle 内的 ChannelConfig.plist 读取 11 个渠道注入值。 +// 详见 §7.0.3 ChannelConfig.plist 母包模式 / ADR-007。 public final class BundleConfig: @unchecked Sendable { public static let shared = BundleConfig() - public private(set) var qiniuDomain = "" - public private(set) var gameId = "" - public private(set) var channel = "" - public private(set) var market = "" - public private(set) var agent = "" - public private(set) var appVersion = "" - public private(set) var gameDir = "" - public private(set) var gameStart = "" - public private(set) var gameConfig = "" + public let qiniuDomain: String + public let gameId: String + public let channel: String + public let gameDir: String + public let gameStart: String + public let gameConfig: String + public let market: String + public let agent: String + public let appVersion: String + public let other: String + public let appleConfig: String - public static func preload() async { - // 异步包装,实际是同步 IO,但放在后台 queue - await Task.detached(priority: .userInitiated) { - BundleConfig.shared.qiniuDomain = readInjected("qiniudomain") - BundleConfig.shared.gameId = readInjected("gameid") - BundleConfig.shared.channel = readInjected("channel") - // ... - }.value + public init(bundle: Bundle = .main) { + let dict = Self.loadPlist(bundle: bundle) + qiniuDomain = dict["qiniudomain"] ?? "" + gameId = dict["gameid"] ?? "" + channel = dict["channel"] ?? "" + gameDir = dict["gamedir"] ?? "" + gameStart = dict["gamestart"] ?? "" + gameConfig = dict["gameconfig"] ?? "" + market = dict["market"] ?? "" + agent = dict["agent"] ?? "" + appVersion = dict["appversion"] ?? "" + other = dict["other"] ?? "" + appleConfig = dict["appleconfig"] ?? "" } - /// 扫描 Bundle 中指定名称的目录,返回其第一个非隐藏子项的名字 - /// (打包脚本约定:每个 key 对应一个 Bundle 目录,目录下唯一子目录名即为配置值) - private static func readInjected(_ key: String) -> String { - let dir = Bundle.main.bundleURL.appendingPathComponent(key) - guard let items = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { - return "" - } - return items.first { !$0.hasPrefix(".") && $0 != ".DS_Store" } ?? "" + private static func loadPlist(bundle: Bundle) -> [String: String] { + guard let url = bundle.url(forResource: "ChannelConfig", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList( + from: data, format: nil) as? [String: String] + else { return [:] } + return plist } } ``` > 七牛 CDN 域名(用于录音上传后的公网 URL 组装)在新项目中统一从 `BundleConfig.shared.qiniuDomain` 读取,不暴露任何全局变量。所有 AudioKit / RecordUploader 等模块通过依赖注入获取 BundleConfig。 +> +> **测试性**:`init(bundle:)` 接受任意 Bundle,单测可注入 mock bundle 验证不同 plist fixture。 ### 7.3 Zip 解压(异步,非阻塞) diff --git a/ylgamehall.xcodeproj/project.pbxproj b/ylgamehall.xcodeproj/project.pbxproj index 4f54316..2264b04 100644 --- a/ylgamehall.xcodeproj/project.pbxproj +++ b/ylgamehall.xcodeproj/project.pbxproj @@ -242,7 +242,7 @@ DEVELOPMENT_TEAM = NX5W3B4QP3; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -306,7 +306,7 @@ DEVELOPMENT_TEAM = NX5W3B4QP3; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; diff --git a/ylgamehall/Resources/ChannelConfig.plist b/ylgamehall/Resources/ChannelConfig.plist new file mode 100644 index 0000000..a97e9be --- /dev/null +++ b/ylgamehall/Resources/ChannelConfig.plist @@ -0,0 +1,56 @@ + + + + + + + qiniudomain + iosaudio.daoqi88.cn + + gameid + G2hw0ubng0zcoI0r4mx3H2yr4GejidwO + + channel + FtJf073aa0d6rI1xD8J1Y42fINTm0ziK + + gamedir + FtJf073aa0d6rI1xD8J1Y42fINTm0ziK + + gamestart + gamehall + + gameconfig + tsgames.daoqi88.cn-config_test-update_jsonv2_test + + market + 2 + + agent + veRa0qrBf0df2K1G4de2tgfmVxB2jxpv + + appversion + 43 + + other + + + appleconfig + + +