Files
iot-dboard/ContentView.swift
2026-08-06 11:11:11 +08:00

358 lines
16 KiB
Swift
Raw Permalink 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.
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) {}
}