diff --git a/ylgamehall/Resources/gamehall.zip b/ylgamehall/Resources/gamehall.zip new file mode 100644 index 0000000..f07681d Binary files /dev/null and b/ylgamehall/Resources/gamehall.zip differ diff --git a/ylgamehall/RootViewController.swift b/ylgamehall/RootViewController.swift index b868e6c..138359e 100644 --- a/ylgamehall/RootViewController.swift +++ b/ylgamehall/RootViewController.swift @@ -40,6 +40,22 @@ final class RootViewController: UIViewController { lobbyIndex = \(SandboxPaths.lobbyIndex.path) lobbyVersionXML = \(SandboxPaths.lobbyVersionXML.path) """) + + // Phase 1.5 烟雾测试:触发 ResourceUnzipper,确认 lobby index.html 存在 + Task { + let start = Date() + do { + try await ResourceUnzipper.shared.ensureReady() + let elapsed = Date().timeIntervalSince(start) + let exists = FileManager.default.fileExists(atPath: SandboxPaths.lobbyIndex.path) + print(""" + [ResourceUnzipper] ensureReady 完成(耗时 \(String(format: "%.3f", elapsed))s) + lobbyIndex 存在: \(exists) + """) + } catch { + print("[ResourceUnzipper] ERROR: \(error)") + } + } } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { diff --git a/ylgamehall/Source/Resource/BundleConfig.swift b/ylgamehall/Source/Resource/BundleConfig.swift index 903437b..c14ec87 100644 --- a/ylgamehall/Source/Resource/BundleConfig.swift +++ b/ylgamehall/Source/Resource/BundleConfig.swift @@ -12,7 +12,7 @@ import Foundation /// /// 多渠道分发:构建后用 `plutil` 修改 `.app/ChannelConfig.plist` + 重签, /// 不需要重新 Xcode build。详见 ADR-007。 -public final class BundleConfig: Sendable { +nonisolated public final class BundleConfig: Sendable { /// App 启动期默认读 main bundle 的 `ChannelConfig.plist`,业务代码统一通过此单例访问。 public static let shared = BundleConfig() diff --git a/ylgamehall/Source/Resource/ResourceUnzipper.swift b/ylgamehall/Source/Resource/ResourceUnzipper.swift new file mode 100644 index 0000000..9a2959a --- /dev/null +++ b/ylgamehall/Source/Resource/ResourceUnzipper.swift @@ -0,0 +1,51 @@ +// +// ResourceUnzipper.swift +// ylgamehall +// +// 启动期把 Bundle 内的 gamehall.zip 解压到 Library/Caches/{gamedir}/。 +// 详见 docs/H5-Native-Implementation-Design.md §1.1 / §7。 +// + +import Foundation +import ZIPFoundation + +/// 大厅 H5 资源包解压器。actor 隔离保证同一进程内并发调用 `ensureReady()` 不重复解压。 +/// +/// 解压触发条件:`{Caches}/{gamedir}/{gamestart}/version.xml` 不存在 +/// (首次安装 / 系统清空 Caches 后命中)。日常启动直接跳过,耗时为 0。 +public actor ResourceUnzipper { + + public static let shared = ResourceUnzipper() + + public enum UnzipError: Error { + case bundleZipNotFound + case unzipFailed(underlying: Error) + } + + private init() {} + + /// 异步确保大厅 H5 资源在 Caches 内就绪。幂等。 + /// + /// - 已就绪(version.xml 存在)→ 立即返回 + /// - 未就绪 → 创建 `{Caches}/{gamedir}/`,解压 `Bundle/gamehall.zip` 到其中 + public func ensureReady() async throws { + if FileManager.default.fileExists(atPath: SandboxPaths.lobbyVersionXML.path) { + return + } + + guard let zipURL = Bundle.main.url(forResource: "gamehall", withExtension: "zip") else { + throw UnzipError.bundleZipNotFound + } + + let destination = SandboxPaths.lobbyRoot + let fm = FileManager.default + + do { + try fm.createDirectory(at: destination, + withIntermediateDirectories: true) + try fm.unzipItem(at: zipURL, to: destination) + } catch { + throw UnzipError.unzipFailed(underlying: error) + } + } +} diff --git a/ylgamehall/Source/Resource/SandboxPaths.swift b/ylgamehall/Source/Resource/SandboxPaths.swift index ff45e2a..1421ee1 100644 --- a/ylgamehall/Source/Resource/SandboxPaths.swift +++ b/ylgamehall/Source/Resource/SandboxPaths.swift @@ -15,29 +15,29 @@ public enum SandboxPaths { // MARK: - 基础沙盒目录 /// `Library/Caches/`(系统空间不足时可能被清,需可重建) - public static var caches: URL { + nonisolated public static var caches: URL { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] } /// `Documents/`(用户级持久化,iCloud 备份) - public static var documents: URL { + nonisolated public static var documents: URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] } /// `.app/` - public static var bundle: URL { + nonisolated public static var bundle: URL { Bundle.main.bundleURL } // MARK: - 大厅 H5 路径(基于 BundleConfig.shared 的当前渠道) /// 大厅 H5 解压根目录:`{Caches}/{gamedir}/` - public static var lobbyRoot: URL { + nonisolated public static var lobbyRoot: URL { caches.appendingPathComponent(BundleConfig.shared.gameDir) } /// 大厅 H5 入口 HTML:`{Caches}/{gamedir}/{gamestart}/index.html` - public static var lobbyIndex: URL { + nonisolated public static var lobbyIndex: URL { let cfg = BundleConfig.shared return caches .appendingPathComponent(cfg.gameDir) @@ -47,7 +47,7 @@ public enum SandboxPaths { /// 大厅 H5 版本标记文件:`{Caches}/{gamedir}/{gamestart}/version.xml` /// 由 ResourceUnzipper 判断是否需要解压初始 gamehall.zip。 - public static var lobbyVersionXML: URL { + nonisolated public static var lobbyVersionXML: URL { let cfg = BundleConfig.shared return caches .appendingPathComponent(cfg.gameDir) @@ -58,12 +58,12 @@ public enum SandboxPaths { // MARK: - 子游戏 H5 路径(参数化,由 SwitchOverGameData 传入) /// 子游戏 H5 解压根目录:`{Caches}/{dir}/` - public static func subGameRoot(_ dir: String) -> URL { + nonisolated public static func subGameRoot(_ dir: String) -> URL { caches.appendingPathComponent(dir) } /// 子游戏 H5 入口 HTML:`{Caches}/{dir}/{start}/index.html` - public static func subGameIndex(_ dir: String, _ start: String) -> URL { + nonisolated public static func subGameIndex(_ dir: String, _ start: String) -> URL { caches.appendingPathComponent(dir) .appendingPathComponent(start) .appendingPathComponent("index.html")