First Push

This commit is contained in:
喆OvO
2026-08-06 11:11:11 +08:00
commit 0fef309972
551 changed files with 15793 additions and 0 deletions
+357
View File
@@ -0,0 +1,357 @@
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) {}
}