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>
This commit is contained in:
joywayer
2026-06-29 08:58:00 +08:00
co-authored by Claude Opus 4.7
parent 960adc5491
commit 04e702a99b
8 changed files with 686 additions and 17 deletions
@@ -68,4 +68,30 @@ public enum SandboxPaths {
.appendingPathComponent(start)
.appendingPathComponent("index.html")
}
// MARK: - App scheme URLPhase 1.F WKURLSchemeHandler
//
// AppSchemeHandler scheme URL +
// host (`ylgame://h5`) same-origin localStorage
// (`lobby` / `subgame/<dir>`)handler
// lobbyRoot / subGameRoot(_:)
/// H5 app:// URL`ylgame://h5/lobby/<gameStart>/index.html`
nonisolated public static var lobbyIndexAppURL: URL {
var c = URLComponents()
c.scheme = AppScheme.scheme
c.host = AppScheme.host
c.path = "/\(AppScheme.lobbyPrefix)/\(BundleConfig.shared.gameStart)/index.html"
// path / force-unwrap
return c.url!
}
/// H5 app:// URL`ylgame://h5/subgame/<dir>/<start>/index.html`
nonisolated public static func subGameIndexAppURL(_ dir: String, _ start: String) -> URL {
var c = URLComponents()
c.scheme = AppScheme.scheme
c.host = AppScheme.host
c.path = "/\(AppScheme.subGamePrefix)/\(dir)/\(start)/index.html"
return c.url!
}
}
@@ -0,0 +1,415 @@
//
// 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-rangeCocos
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"
]
}
@@ -30,6 +30,18 @@ public final class BridgedWebView: UIView {
configuration.mediaTypesRequiringUserActionForPlayback = []
configuration.processPool = SharedProcessPool.shared
// URL scheme Phase 1.F
// + H5 `ylgame://h5/...` AppSchemeHandler
// docs/H5-Native-Implementation-Design.md §17
// - file:// origin nullXHR/fetch scheme origin
// - + `ylgame://h5` host same-origin
// localStorage file://
// - Cocos H5
configuration.setURLSchemeHandler(
AppSchemeHandler.shared,
forURLScheme: AppScheme.scheme
)
// documentStart WebViewJavascriptBridge.js
// .atDocumentStart H5 window.WebViewJavascriptBridge
if let url = Bundle.main.url(forResource: "WebViewJavascriptBridge",
@@ -245,13 +245,13 @@ public final class SubGameViewController: UIViewController {
try writer.writeBattery(BatteryMonitor.shared.currentLevel)
try writer.writeNetwork(NetworkMonitor.shared.currentCode)
// 3. H5allowingReadAccessTo subGameRoot
// 3. H5Phase 1.F loadFileURL AppSchemeHandler
// URLylgame://h5/subgame/<dir>/<start>/index.html
// host"h5" same-origin localStorage
// subGameRoot(<dir>) allowingReadAccessTo
splash.update(text: "进入子游戏...", progress: nil)
let indexURL = SandboxPaths.subGameIndex(effectiveGameDir, request.gameStart)
bridgedWebView.webView.loadFileURL(
indexURL,
allowingReadAccessTo: SandboxPaths.subGameRoot(effectiveGameDir)
)
let indexURL = SandboxPaths.subGameIndexAppURL(effectiveGameDir, request.gameStart)
bridgedWebView.webView.load(URLRequest(url: indexURL))
}
/// resolve
@@ -407,12 +407,13 @@ public final class WebContainerViewController: UIViewController {
// docs/H5-Native-Implementation-Design.md §7.5
try writeAppDataFiles(resolved: resolved)
// 7. H5allowingReadAccessTo lobbyRoot
// 7. H5Phase 1.F loadFileURL AppSchemeHandler
// URLylgame://h5/lobby/<gameStart>/index.html
// AppSchemeHandler /lobby/<rest> lobbyRoot/<rest>
// H5 ../foo.js lobbyRoot file://
// allowingReadAccessTo
splash.update(text: "加载大厅...", progress: nil)
bridgedWebView.webView.loadFileURL(
SandboxPaths.lobbyIndex,
allowingReadAccessTo: SandboxPaths.lobbyRoot
)
bridgedWebView.webView.load(URLRequest(url: SandboxPaths.lobbyIndexAppURL))
}
/// 4 app_*.js loadFileURL monitor