import SwiftUI import WebKit import AuthenticationServices import UIKit // MARK: - AppSettings(域名存储:UserDefaults) enum AppSettings { static let baseURLKey = "dashboard_base_url" /// 规范化用户输入的域名:补 https://、去尾斜杠、校验格式。 static func normalizedBaseURL(_ raw: String) -> URL? { var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) if s.isEmpty { return nil } if !s.hasPrefix("http://") && !s.hasPrefix("https://") { s = "https://" + s } while s.hasSuffix("/") { s.removeLast() } guard let url = URL(string: s), let scheme = url.scheme, scheme == "http" || scheme == "https", url.host != nil else { return nil } return url } } // MARK: - Coordinator class WebViewCoordinator: NSObject, WKNavigationDelegate { weak var webView: WKWebView? var onPageFinished: (() -> Void)? var onDashboardIntercepted: ((URL) -> Void)? var onPasskeyIntercepted: ((URL) -> Void)? func load(url: URL) { // Caller must set onPageFinished BEFORE calling load() webView?.load(URLRequest(url: url)) } func webView(_: WKWebView, didFinish _: WKNavigation!) { onPageFinished?() } func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { onPageFinished?() } func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { onPageFinished?() } func webView(_: WKWebView, decidePolicyFor navAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard let url = navAction.request.url else { decisionHandler(.allow); return } // dashboard:// 回调(Passkey 小窗完成/失败) if url.scheme == "dashboard" { onDashboardIntercepted?(url) decisionHandler(.cancel) return } // /login 不再拦截:登录页在 WKWebView 内直接渲染(与网页版一致), // cookie 直接进入主容器会话;仅当页面内发起 Passkey 时,中间件 // 跳 /passkey,这里拦截并在 ASWebAuthenticationSession 小窗中打开。 if url.path == "/passkey" || url.path.hasPrefix("/passkey/") { onPasskeyIntercepted?(url) decisionHandler(.cancel) return } decisionHandler(.allow) } } // MARK: - 登录小窗(ASWebAuthenticationSession) class AuthSessionManager: NSObject, ASWebAuthenticationPresentationContextProviding { private var currentSession: ASWebAuthenticationSession? func start(url: URL, scheme: String, completion: @escaping (URL?) -> Void) { let s = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { cb, _ in self.currentSession = nil completion(cb) } // 每次小窗使用全新的临时会话,不含旧 cookie —— 修复"App 已登出但小窗 // 仍带旧 cookie 直接进入主页 → 无法登录 → 跳回 /login → 无限循环"的问题。 s.prefersEphemeralWebBrowserSession = true s.presentationContextProvider = self currentSession = s s.start() } func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene }.flatMap { $0.windows } .first { $0.isKeyWindow } ?? ASPresentationAnchor() } } struct WebView: UIViewRepresentable { let url: URL let coordinator: WebViewCoordinator var onPageFinished: (() -> Void)? func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() config.mediaTypesRequiringUserActionForPlayback = [] config.allowsInlineMediaPlayback = true // 注入 iOS 套壳 App 标志:Web 端 Passkey 中间件据此判定"当前运行在 App 内", // 从而跳转 /passkey 专有页面走原生 ASAuthorizationController。 // 用注入标志而非覆盖 User-Agent,避免破坏真实 UA 导致服务器/反代误判。 let script = WKUserScript(source: "window.__CAL_DISPLAY_APP__ = true;", injectionTime: .atDocumentStart, forMainFrameOnly: true) config.userContentController.addUserScript(script) let wv = WKWebView(frame: .zero, configuration: config) wv.navigationDelegate = context.coordinator wv.scrollView.bounces = true context.coordinator.webView = wv context.coordinator.onPageFinished = onPageFinished wv.load(URLRequest(url: url)) return wv } func updateUIView(_: WKWebView, context _: Context) {} func makeCoordinator() -> WebViewCoordinator { coordinator } } // MARK: - 域名设置 / 欢迎页(iPhone 上下排列,iPad 左右排列) struct DomainSetupView: View { @Environment(\.horizontalSizeClass) private var sizeClass @AppStorage(AppSettings.baseURLKey) private var baseURLString = "" @State private var input = "" @State private var showInput = false // false=欢迎页(已有域名);true=填写页 @State private var error: String? var onEnter: () -> Void private var hasDomain: Bool { !baseURLString.isEmpty } private var displayURL: String { AppSettings.normalizedBaseURL(baseURLString)?.absoluteString ?? baseURLString } var body: some View { ZStack { Color(red: 15/255, green: 17/255, blue: 23/255).ignoresSafeArea() // iPhone(compact)上下排列;iPad(regular)左右排列 if sizeClass == .compact { VStack(spacing: 24) { content }.padding(32) } else { HStack(spacing: 40) { content }.padding(56) } } .onAppear { showInput = !hasDomain if hasDomain { input = baseURLString } } } @ViewBuilder private var content: some View { if hasDomain && !showInput { // ─── 欢迎页 ─── Group { Text("🎛️").font(.system(size: 48)) Text("欢迎回来!").font(.largeTitle.weight(.semibold)).foregroundColor(.white) Text(displayURL).font(.subheadline).foregroundColor(.white.opacity(0.55)) Button(action: { onEnter() }) { Text("继续").font(.headline).foregroundColor(.white) .frame(minWidth: 160).padding(.vertical, 12).padding(.horizontal, 32) .background(Color(red: 74/255, green: 144/255, blue: 217/255)) .cornerRadius(12) } Button(action: { showInput = true; input = baseURLString }) { Text("更改域名").font(.subheadline).foregroundColor(.white.opacity(0.7)) .padding(.vertical, 8).padding(.horizontal, 20) } } } else { // ─── 填写页 ─── Group { Text("您好!请填写连接域名").font(.title2.weight(.semibold)).foregroundColor(.white) TextField("https://your-domain.com", text: $input) .textFieldStyle(PlainTextFieldStyle()) .keyboardType(.URL) .autocapitalization(.none) .foregroundColor(.white) .padding(14) .background(Color.white.opacity(0.08)) .cornerRadius(12) .frame(maxWidth: 420) if let error = error { Text(error).font(.footnote).foregroundColor(Color(red: 224/255, green: 85/255, blue: 85/255)) } Button(action: save) { Text("保存").font(.headline).foregroundColor(.white) .frame(minWidth: 160).padding(.vertical, 12).padding(.horizontal, 32) .background(Color(red: 74/255, green: 144/255, blue: 217/255)) .cornerRadius(12) } } } } private func save() { guard let url = AppSettings.normalizedBaseURL(input) else { error = "域名格式无效,请输入如 dashboard.example.com" return } baseURLString = url.absoluteString error = nil onEnter() } } // MARK: - MainWebView(主界面:WebView + 登录/Passkey 拦截) struct MainWebView: View { let baseURL: URL @State private var showSplash = true @State private var coordinator = WebViewCoordinator() @State private var authSession = AuthSessionManager() var body: some View { ZStack { WebView( url: baseURL, coordinator: coordinator, onPageFinished: { DispatchQueue.main.async { withAnimation(.easeOut(duration: 0.35)) { showSplash = false } } } ) .ignoresSafeArea() .opacity(showSplash ? 0 : 1) if showSplash { SplashView().transition(.opacity) } } .ignoresSafeArea() .id(baseURL.absoluteString) // 域名变化时重建 WKWebView .onAppear { coordinator.onDashboardIntercepted = { [self] in handleDashboard(url: $0) } coordinator.onPasskeyIntercepted = { [self] in handlePasskey(url: $0) } } } // dashboard:// 回调:仅 Passkey 小窗使用(登录已改为主容器内完成, // 不再需要 dashboard://callback + session-token 交换)。 private func handleDashboard(url callbackURL: URL) { guard callbackURL.host == "passkey" else { return } handlePasskeyCallback(callbackURL) } // /passkey 被 WKNavigationDelegate 拦截后:用 ASWebAuthenticationSession 小窗 // 打开该页面(Safari 环境原生支持 WebAuthn/Passkey,无需 App 关联域名); // 小窗为 ephemeral 会话(prefersEphemeralWebBrowserSession),每次全新、 // 不携带旧 cookie,避免会话串扰。 private func handlePasskey(url passkeyURL: URL) { DispatchQueue.main.async { authSession.start(url: passkeyURL, scheme: "dashboard") { [self] cb in guard let cb = cb else { // 用户取消小窗 → 通知页面中间件取消 DispatchQueue.main.async { coordinator.webView?.evaluateJavaScript( "window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel('认证已取消');", completionHandler: nil) } return } handleDashboard(url: cb) // dashboard://passkey?sid=... 或 ?error=... } } } // dashboard://passkey 回调:小窗完成(?sid=)或失败(?error=)。 // credential 较大不随 URL 回传,而是从服务端一次性 result 接口取回。 private func handlePasskeyCallback(_ url: URL) { guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } let errorMsg = comps.queryItems?.first(where: { $0.name == "error" })?.value let sid = comps.queryItems?.first(where: { $0.name == "sid" })?.value DispatchQueue.main.async { guard let wv = self.coordinator.webView else { return } if let errorMsg = errorMsg { wv.evaluateJavaScript( "window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel(" + Self.jsStringLiteral(errorMsg) + ");", completionHandler: nil) } else if let sid = sid { self.fetchPasskeyResult(sid: sid, webView: wv) } } } private func fetchPasskeyResult(sid: String, webView: WKWebView) { var comps = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! comps.path = "/api/passkey/stash/\(sid)/result" guard let url = comps.url else { return } var req = URLRequest(url: url) req.timeoutInterval = 10 URLSession.shared.dataTask(with: req) { data, _, error in DispatchQueue.main.async { if let error = error { webView.evaluateJavaScript( "window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel(" + Self.jsStringLiteral(error.localizedDescription) + ");", completionHandler: nil) return } guard let data = data, let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let credential = obj["credential"], let credData = try? JSONSerialization.data(withJSONObject: credential), let json = String(data: credData, encoding: .utf8) else { webView.evaluateJavaScript( "window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel('Passkey 结果获取失败');", completionHandler: nil) return } // json 是合法 JSON 字面量,可直接作为 JS 实参 webView.evaluateJavaScript( "window.PasskeyBridge && window.PasskeyBridge.__iosComplete && window.PasskeyBridge.__iosComplete(" + json + ");", completionHandler: nil) } }.resume() } /// 把字符串编码为合法的 JS 字符串字面量(含引号与转义)。 private static func jsStringLiteral(_ s: String) -> String { guard let data = try? JSONSerialization.data(withJSONObject: [s]), let arr = String(data: data, encoding: .utf8), arr.count >= 2 else { return "\"\"" } return String(arr.dropFirst().dropLast()) } } // MARK: - ContentView(入口:无域名 → 填写页;有域名 → 欢迎页/主界面) struct ContentView: View { @AppStorage(AppSettings.baseURLKey) private var baseURLString = "" @State private var inApp = false var body: some View { Group { if inApp, let url = AppSettings.normalizedBaseURL(baseURLString) { MainWebView(baseURL: url) } else { DomainSetupView(onEnter: { inApp = true }) } } .onAppear { // Dashboard = always-on display, prevent auto-lock UIApplication.shared.isIdleTimerDisabled = true } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false } } } // MARK: - Splash struct SplashView: View { var body: some View { ZStack { Color(red: 15/255, green: 17/255, blue: 23/255).ignoresSafeArea() VStack(spacing: 28) { Text("Dashboard").font(.system(size: 30, weight: .semibold)).foregroundColor(.white) if #available(iOS 15.0, *) { ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)).scaleEffect(1.3) } else { UIActivity() } Text("加载中...").font(.system(size: 14)).foregroundColor(.white.opacity(0.5)) } } } } struct UIActivity: UIViewRepresentable { func makeUIView(context _: Context) -> UIActivityIndicatorView { let v = UIActivityIndicatorView(style: .medium); v.color = .white; v.startAnimating(); return v } func updateUIView(_: UIActivityIndicatorView, context _: Context) {} }