First Push
This commit is contained in:
@@ -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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user