Files
youle_app_ios_v2/ylgamehall/Source/WebView/AppSchemeHandler.swift
T
joywayerandClaude Opus 4.7 04e702a99b Phase 1.F: WebView 加载从 file:// 迁到 ylgame://h5 自定义 scheme
为未来 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>
2026-06-29 08:58:00 +08:00

416 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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: - Routingnonisolated 纯函数)
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 buildingIO 队列调用)
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"
]
}