为未来 Cocos 子游戏 H5 build 适配铺路(file:// 下 Cocos XHR/fetch 受 null-origin 限制几乎必踩坑),同时保留大厅 + 子游戏跨页 localStorage 共享语义(用单虚拟 host `h5` 让所有 H5 same-origin)。 变更: - 新增 AppSchemeHandler(WKURLSchemeHandler 单例 + Range/MIME/异步 IO/ 取消语义;闭包只携 Sendable ObjectIdentifier,不捕获 task) - SandboxPaths 加 lobbyIndexAppURL / subGameIndexAppURL builders - BridgedWebView 注册 scheme handler(WKWebView init 前) - WebContainerViewController / SubGameViewController 的 loadFileURL → webView.load(URLRequest),OverlayViewController 不变 文档: - Plan 新增 Phase 1.F (1.18-1.21) + ADR-010 决策记录 + 进度勾选 - Design 新增 §7.6 包含 URL 结构 / 实现要点 / 等价性表 / Cocos 预检脚本 - Contract §0.2 / §4.1 / §10 验收清单同步切换说明(H5 可观察差异: location.protocol "file:" → "ylgame:",项目方已 grep 确认现网 H5 不依赖此字面) 存量影响:file:// → ylgame:// origin 切换时老用户 localStorage 一次性 清零,已与项目方确认业务可接受、不做迁移补偿。 BuildProject 通过。Plan 进度已勾选 1.18/1.19/1.20。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
416 lines
15 KiB
Swift
416 lines
15 KiB
Swift
//
|
||
// AppSchemeHandler.swift
|
||
// ylgamehall
|
||
//
|
||
// WKURLSchemeHandler 实现,承接 `ylgame://h5/...` 自定义 scheme。
|
||
// 替代原 loadFileURL 路径,让大厅 + 所有子游戏跑在同一个稳定 origin
|
||
// (`ylgame://h5`) 下,从而:
|
||
// 1) 共享 localStorage(同 scheme + 同 host = 同 security origin)
|
||
// 2) 摆脱 file:// 的 XHR/fetch null-origin 限制(Cocos H5 build 适配前置)
|
||
// 3) 给 `<audio>/<video>` 提供 Range 请求支持(Cocos 音频 seek)
|
||
//
|
||
// 路由:
|
||
// ylgame://h5/lobby/<rest...> → SandboxPaths.lobbyRoot/<rest...>
|
||
// ylgame://h5/subgame/<dir>/<rest...> → SandboxPaths.subGameRoot(dir)/<rest...>
|
||
//
|
||
// 设计 / Phase 见 docs/H5-Native-Implementation-Design.md §17(新增)/
|
||
// docs/Development-Plan.md Phase 1.F。
|
||
//
|
||
|
||
import Foundation
|
||
@preconcurrency import WebKit
|
||
import os.log
|
||
|
||
// MARK: - 常量(项目前缀)
|
||
|
||
/// 自定义 scheme + host + 路径前缀常量。**所有成员 nonisolated**,让
|
||
/// `SandboxPaths`(nonisolated)能在任意上下文拼 URL(项目启用了
|
||
/// MainActor-by-default 默认隔离,未标 nonisolated 的 static 会被推断为
|
||
/// MainActor 隔离,背景 IO 队列无法访问)。
|
||
public enum AppScheme {
|
||
/// scheme = "ylgame"(项目前缀,避免与其它 app 自定义 scheme 冲突)
|
||
nonisolated public static let scheme = "ylgame"
|
||
/// 单虚拟 host = "h5"。**所有 H5 页(大厅 + 所有子游戏)共用此 host**,
|
||
/// 确保 same-origin → 共享 localStorage / IndexedDB / sessionStorage。
|
||
nonisolated public static let host = "h5"
|
||
/// 大厅根路径段:`/lobby/...`
|
||
nonisolated public static let lobbyPrefix = "lobby"
|
||
/// 子游戏根路径段:`/subgame/<dir>/...`
|
||
nonisolated public static let subGamePrefix = "subgame"
|
||
}
|
||
|
||
// MARK: - Handler
|
||
|
||
/// 自定义 URL scheme 的 WKURLSchemeHandler。**单例**注册到所有 BridgedWebView 的
|
||
/// WKWebViewConfiguration(同一实例可挂多个 WKWebViewConfiguration;每个
|
||
/// configuration 内每个 scheme 只能注册一次)。
|
||
@MainActor
|
||
public final class AppSchemeHandler: NSObject, WKURLSchemeHandler {
|
||
|
||
public static let shared = AppSchemeHandler()
|
||
|
||
private static let log = Logger(subsystem: "ylgamehall", category: "AppSchemeHandler")
|
||
|
||
/// 后台 IO 队列。所有 file IO + Range parsing 在此运行,避免阻塞主线程。
|
||
private let ioQueue = DispatchQueue(
|
||
label: "ylgamehall.app-scheme-handler.io",
|
||
qos: .userInitiated,
|
||
attributes: .concurrent
|
||
)
|
||
|
||
/// 跟踪进行中的 task。**仅主线程访问**(start / stop / deliver 都在 MainActor)。
|
||
/// 用 ObjectIdentifier 作 key 是为了让背景 IO 队列只携带 Sendable 的 key 跨边界,
|
||
/// 不把非 Sendable 的 `any WKURLSchemeTask` 捕获进 `@Sendable` 闭包。
|
||
/// WKURLSchemeTask 协议规定 stop 后不能再向 task 发任何消息(否则 NSException),
|
||
/// 用 `cancelledKeys` 标记取消、`tasksByKey` 持强引用配套使用。
|
||
private var tasksByKey: [ObjectIdentifier: any WKURLSchemeTask] = [:]
|
||
private var cancelledKeys: Set<ObjectIdentifier> = []
|
||
|
||
private override init() {
|
||
super.init()
|
||
}
|
||
|
||
// MARK: - WKURLSchemeHandler
|
||
|
||
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
||
let key = ObjectIdentifier(urlSchemeTask)
|
||
tasksByKey[key] = urlSchemeTask
|
||
|
||
guard let url = urlSchemeTask.request.url else {
|
||
finish(key: key, withError: AppSchemeError.invalidURL)
|
||
return
|
||
}
|
||
let rangeHeader = urlSchemeTask.request.value(forHTTPHeaderField: "Range")
|
||
|
||
// 路由 + 文件路径解析(主线程做,廉价)
|
||
let resolved: ResolvedRequest
|
||
do {
|
||
resolved = try Self.resolveRequest(url: url, rangeHeader: rangeHeader)
|
||
} catch {
|
||
finish(key: key, withError: error)
|
||
return
|
||
}
|
||
|
||
// 读盘 + 响应 → 后台队列。注意:闭包只捕获 Sendable 的 `key` / `resolved`,
|
||
// 不捕获非 Sendable 的 `urlSchemeTask`,避免数据竞争告警。
|
||
ioQueue.async { [weak self] in
|
||
let result = Self.readAndBuildResponse(resolved: resolved)
|
||
Task { @MainActor in
|
||
self?.deliver(result: result, forKey: key)
|
||
}
|
||
}
|
||
}
|
||
|
||
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
|
||
let key = ObjectIdentifier(urlSchemeTask)
|
||
// 标记取消,io 队列完成后 deliver 见此标志直接短路。tasksByKey 在 deliver
|
||
// / finish 内清理,不在此处提前移除(避免与背景 IO 完成竞态)。
|
||
cancelledKeys.insert(key)
|
||
}
|
||
|
||
// MARK: - Routing(nonisolated 纯函数)
|
||
|
||
fileprivate struct ResolvedRequest {
|
||
let fileURL: URL
|
||
let url: URL
|
||
let rangeHeader: String?
|
||
}
|
||
|
||
/// 把 `ylgame://h5/<prefix>/<rest>` 解析为沙盒实际文件路径。
|
||
/// - lobby 前缀:rest 全段拼到 lobbyRoot
|
||
/// - subgame 前缀:第一段是 dir,剩余拼到 subGameRoot(dir)
|
||
nonisolated fileprivate static func resolveRequest(
|
||
url: URL,
|
||
rangeHeader: String?
|
||
) throws -> ResolvedRequest {
|
||
guard url.scheme?.lowercased() == AppScheme.scheme,
|
||
url.host?.lowercased() == AppScheme.host
|
||
else {
|
||
throw AppSchemeError.invalidURL
|
||
}
|
||
|
||
// path 形如 "/lobby/gameStart/index.html" 或 "/subgame/<dir>/<start>/index.html"
|
||
let segments = url.path
|
||
.split(separator: "/", omittingEmptySubsequences: true)
|
||
.map(String.init)
|
||
guard let prefix = segments.first else {
|
||
throw AppSchemeError.invalidURL
|
||
}
|
||
|
||
let fileURL: URL
|
||
switch prefix {
|
||
case AppScheme.lobbyPrefix:
|
||
let rest = segments.dropFirst().joined(separator: "/")
|
||
fileURL = SandboxPaths.lobbyRoot.appendingPathComponent(rest)
|
||
|
||
case AppScheme.subGamePrefix:
|
||
guard segments.count >= 2 else {
|
||
throw AppSchemeError.invalidURL
|
||
}
|
||
let dir = segments[1]
|
||
let rest = segments.dropFirst(2).joined(separator: "/")
|
||
fileURL = SandboxPaths.subGameRoot(dir).appendingPathComponent(rest)
|
||
|
||
default:
|
||
throw AppSchemeError.invalidURL
|
||
}
|
||
|
||
return ResolvedRequest(fileURL: fileURL, url: url, rangeHeader: rangeHeader)
|
||
}
|
||
|
||
// MARK: - Reading + response building(IO 队列调用)
|
||
|
||
fileprivate enum BuildResult {
|
||
case ok(HTTPURLResponse, Data)
|
||
case fail(AppSchemeError)
|
||
}
|
||
|
||
nonisolated fileprivate static func readAndBuildResponse(
|
||
resolved: ResolvedRequest
|
||
) -> BuildResult {
|
||
let fileURL = resolved.fileURL
|
||
let fm = FileManager.default
|
||
|
||
guard fm.fileExists(atPath: fileURL.path),
|
||
let attrs = try? fm.attributesOfItem(atPath: fileURL.path),
|
||
let size = (attrs[.size] as? NSNumber)?.intValue
|
||
else {
|
||
return .fail(.fileNotFound(fileURL.path))
|
||
}
|
||
|
||
let mime = MimeMap.mime(forExtension: fileURL.pathExtension)
|
||
|
||
// Range 请求 → 206
|
||
if let rangeHeader = resolved.rangeHeader,
|
||
let (start, end) = parseRange(rangeHeader, totalSize: size) {
|
||
do {
|
||
let handle = try FileHandle(forReadingFrom: fileURL)
|
||
defer { try? handle.close() }
|
||
try handle.seek(toOffset: UInt64(start))
|
||
let length = end - start + 1
|
||
let data = try handle.read(upToCount: length) ?? Data()
|
||
let response = makePartialResponse(
|
||
url: resolved.url,
|
||
mime: mime,
|
||
start: start,
|
||
end: end,
|
||
totalSize: size,
|
||
contentLength: data.count
|
||
)
|
||
return .ok(response, data)
|
||
} catch {
|
||
return .fail(.readFailed(error))
|
||
}
|
||
}
|
||
|
||
// 全文件 → 200
|
||
do {
|
||
let data = try Data(contentsOf: fileURL, options: [.mappedIfSafe])
|
||
let response = makeFullResponse(
|
||
url: resolved.url,
|
||
mime: mime,
|
||
contentLength: data.count
|
||
)
|
||
return .ok(response, data)
|
||
} catch {
|
||
return .fail(.readFailed(error))
|
||
}
|
||
}
|
||
|
||
nonisolated private static func makeFullResponse(
|
||
url: URL,
|
||
mime: String,
|
||
contentLength: Int
|
||
) -> HTTPURLResponse {
|
||
let headers: [String: String] = [
|
||
"Content-Type": mime,
|
||
"Content-Length": "\(contentLength)",
|
||
// 防御性 CORS:自定义 scheme 同源本不需要 preflight,但部分 Cocos build
|
||
// 在 fetch 默认 mode='cors' 下会走 preflight,发回 * 兜底
|
||
"Access-Control-Allow-Origin": "*",
|
||
// 沙盒文件随升级路径改变(lobby zip 升级直接覆盖原目录),禁强缓存避免错版
|
||
"Cache-Control": "no-cache",
|
||
"Accept-Ranges": "bytes"
|
||
]
|
||
return HTTPURLResponse(
|
||
url: url,
|
||
statusCode: 200,
|
||
httpVersion: "HTTP/1.1",
|
||
headerFields: headers
|
||
)!
|
||
}
|
||
|
||
nonisolated private static func makePartialResponse(
|
||
url: URL,
|
||
mime: String,
|
||
start: Int,
|
||
end: Int,
|
||
totalSize: Int,
|
||
contentLength: Int
|
||
) -> HTTPURLResponse {
|
||
let headers: [String: String] = [
|
||
"Content-Type": mime,
|
||
"Content-Length": "\(contentLength)",
|
||
"Content-Range": "bytes \(start)-\(end)/\(totalSize)",
|
||
"Access-Control-Allow-Origin": "*",
|
||
"Cache-Control": "no-cache",
|
||
"Accept-Ranges": "bytes"
|
||
]
|
||
return HTTPURLResponse(
|
||
url: url,
|
||
statusCode: 206,
|
||
httpVersion: "HTTP/1.1",
|
||
headerFields: headers
|
||
)!
|
||
}
|
||
|
||
/// 解析 Range 头:支持 `bytes=start-end` / `bytes=start-` / `bytes=-suffix`。
|
||
/// 不支持 multi-range(逗号分隔),Cocos 不发。
|
||
nonisolated private static func parseRange(
|
||
_ header: String,
|
||
totalSize: Int
|
||
) -> (Int, Int)? {
|
||
guard header.lowercased().hasPrefix("bytes=") else { return nil }
|
||
let spec = String(header.dropFirst("bytes=".count))
|
||
if spec.contains(",") { return nil }
|
||
let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
|
||
guard parts.count == 2 else { return nil }
|
||
let startStr = String(parts[0])
|
||
let endStr = String(parts[1])
|
||
// bytes=-N → 最后 N 字节
|
||
if startStr.isEmpty, let suffix = Int(endStr), suffix > 0 {
|
||
let start = max(totalSize - suffix, 0)
|
||
return (start, totalSize - 1)
|
||
}
|
||
guard let start = Int(startStr), start >= 0, start < totalSize else { return nil }
|
||
if endStr.isEmpty {
|
||
return (start, totalSize - 1)
|
||
}
|
||
guard let end = Int(endStr), end >= start, end < totalSize else { return nil }
|
||
return (start, end)
|
||
}
|
||
|
||
// MARK: - Delivery(主线程)
|
||
|
||
private func deliver(result: BuildResult, forKey key: ObjectIdentifier) {
|
||
// task 已被 stop 标记 → 一律不向 task 发任何消息(否则 NSException)。
|
||
// 即使如此,仍要从两个表里清理引用避免泄漏。
|
||
guard let urlSchemeTask = tasksByKey.removeValue(forKey: key) else { return }
|
||
if cancelledKeys.remove(key) != nil { return }
|
||
|
||
switch result {
|
||
case .ok(let response, let data):
|
||
urlSchemeTask.didReceive(response)
|
||
urlSchemeTask.didReceive(data)
|
||
urlSchemeTask.didFinish()
|
||
Self.log.debug("→ \(response.statusCode, privacy: .public) \(response.url?.absoluteString ?? "", privacy: .public) \(data.count, privacy: .public)B")
|
||
case .fail(let error):
|
||
urlSchemeTask.didFailWithError(error.toNSError)
|
||
Self.log.error("✗ \(error.debugDescription, privacy: .public)")
|
||
}
|
||
}
|
||
|
||
private func finish(key: ObjectIdentifier, withError error: any Error) {
|
||
guard let urlSchemeTask = tasksByKey.removeValue(forKey: key) else { return }
|
||
if cancelledKeys.remove(key) != nil { return }
|
||
let ns = (error as? AppSchemeError)?.toNSError ?? (error as NSError)
|
||
urlSchemeTask.didFailWithError(ns)
|
||
}
|
||
}
|
||
|
||
// MARK: - Errors
|
||
|
||
public enum AppSchemeError: Error, CustomDebugStringConvertible, Sendable {
|
||
case invalidURL
|
||
case fileNotFound(String)
|
||
case readFailed(any Error)
|
||
|
||
public var debugDescription: String {
|
||
switch self {
|
||
case .invalidURL: return "invalid url"
|
||
case .fileNotFound(let p): return "file not found: \(p)"
|
||
case .readFailed(let e): return "read failed: \(e)"
|
||
}
|
||
}
|
||
|
||
var toNSError: NSError {
|
||
switch self {
|
||
case .invalidURL:
|
||
return NSError(
|
||
domain: "AppSchemeHandler",
|
||
code: -1,
|
||
userInfo: [NSLocalizedDescriptionKey: "invalid url"]
|
||
)
|
||
case .fileNotFound(let p):
|
||
return NSError(
|
||
domain: "AppSchemeHandler",
|
||
code: 404,
|
||
userInfo: [NSLocalizedDescriptionKey: "not found: \(p)"]
|
||
)
|
||
case .readFailed(let e):
|
||
return e as NSError
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - MIME map
|
||
|
||
/// 扩展名 → MIME 映射。Cocos H5 build 常用扩展全打表,未命中走
|
||
/// `application/octet-stream`(WKWebView 仍能下载,只是不会自动按内容类型行为)。
|
||
/// `nonisolated` 让 IO 队列能直接调用(项目 MainActor-by-default)。
|
||
enum MimeMap {
|
||
nonisolated static func mime(forExtension ext: String) -> String {
|
||
let lower = ext.lowercased()
|
||
return table[lower] ?? "application/octet-stream"
|
||
}
|
||
|
||
nonisolated private static let table: [String: String] = [
|
||
// 文本类
|
||
"html": "text/html; charset=utf-8",
|
||
"htm": "text/html; charset=utf-8",
|
||
"js": "text/javascript; charset=utf-8",
|
||
"mjs": "text/javascript; charset=utf-8",
|
||
"css": "text/css; charset=utf-8",
|
||
"json": "application/json; charset=utf-8",
|
||
"txt": "text/plain; charset=utf-8",
|
||
"xml": "application/xml; charset=utf-8",
|
||
"atlas": "text/plain; charset=utf-8", // Cocos atlas 文件
|
||
"fnt": "text/plain; charset=utf-8", // Cocos bitmap font
|
||
"plist": "application/x-plist",
|
||
"wasm": "application/wasm",
|
||
|
||
// 图片
|
||
"png": "image/png",
|
||
"jpg": "image/jpeg",
|
||
"jpeg": "image/jpeg",
|
||
"gif": "image/gif",
|
||
"webp": "image/webp",
|
||
"svg": "image/svg+xml",
|
||
"ico": "image/x-icon",
|
||
"bmp": "image/bmp",
|
||
|
||
// 音频
|
||
"mp3": "audio/mpeg",
|
||
"ogg": "audio/ogg",
|
||
"oga": "audio/ogg",
|
||
"wav": "audio/wav",
|
||
"m4a": "audio/mp4",
|
||
"aac": "audio/aac",
|
||
|
||
// 视频
|
||
"mp4": "video/mp4",
|
||
"m4v": "video/mp4",
|
||
"webm": "video/webm",
|
||
"mov": "video/quicktime",
|
||
|
||
// 字体
|
||
"ttf": "font/ttf",
|
||
"otf": "font/otf",
|
||
"woff": "font/woff",
|
||
"woff2": "font/woff2",
|
||
"eot": "application/vnd.ms-fontobject",
|
||
|
||
"bin": "application/octet-stream"
|
||
]
|
||
}
|