512d7563ee
- 引擎 / 鉴权跨平台 in-place(iOS 路径 #if 守卫、逐字不变,仅加 macOS/AppKit 分支):EngineController(import 守卫 + 剪贴板 UIPasteboard→NSPasteboard 上下行两处 + device_type 平台条件)、EngineWebView(UIViewRepresentable→typealias 统一后分 NSViewRepresentable)、Theme(动态色 Color(nsColor:dynamicProvider) 同 RGB)、DeviceNameStore(Host.current().localizedName)、PushRegistry(NSApplication.registerForRemoteNotifications)、Auth/BrokerLogin(presentationAnchor→NSApplication.keyWindow)、Auth/AuthManager(死 import UIKit 守卫)
- BackgroundTaskManager:整文件 #if os(iOS)(BGContinuedProcessingTask 为 API_UNAVAILABLE(macos)、编译级拒绝)+ macOS 同签名 no-op stub,使 EngineController.syncBackgroundTask 调用点无须改动
- device_type 修正(非机械):Mac 原生客户端 boot 注入 device_type=macos(与桌面 Wails 客户端一致,后端 / 各端已识别),不再硬编 ios——否则其他端设备列表图标错、且触发其 isWakeable 的 contains("ios") 误判本 Mac 可推送唤醒
- CommilitiaDropMac target 纳入「引擎 + 登录」最小集:Shared + Sources/Engine + Sources/Auth(除 iOS 专属 LoginView)+ RecordsStore / PushRegistry / LocalNetworkPermission / DeviceNameStore / BackgroundTaskManager + Resources/i18n;依赖 WebRTC;Info.plist 加 cdrop:// 回调 scheme(ASWebAuthenticationSession OAuth 回调)+ 本地网络(NSLocalNetworkUsageDescription / NSBonjourServices _cdrop._tcp)
- Mac 壳接引擎:CommilitiaDropMacApp(@State auth + engine,MacAppRoot 登录门 + 会话激活分桶记录)、MacLoginView(Broker 账户登录,免相机)、MacRootView(背景挂离屏引擎 WebView 保活 + 设备真实 presence 列表 + 设置账号/登出/引擎日志)、MacMenuContent(账号 + 在线设备数)。传输/文件/消息详情暂占位,随 Phase 3b 复用 iOS 视图
- 验证:just mac-build(CommilitiaDropMac.app)+ iOS 模拟器(CommilitiaDrop.app)均 BUILD SUCCEEDED。运行期登录 / 引擎联通待真机自测(Broker OAuth 需账号交互)
402 lines
18 KiB
Swift
402 lines
18 KiB
Swift
import AuthenticationServices
|
||
import Foundation
|
||
import Observation
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#endif
|
||
|
||
// 扫码登录(新设备侧)+ 会话持有。iOS 显示二维码,已登录的 web / 桌面设备用“扫码登录”
|
||
// 扫码批准,本端轮询 /api/auth/qr/status 拿到自签会话。字段 / 流程见后端 internal/httpapi/
|
||
// qr.go。登录是纯 REST(不经引擎 WebView),故 engine.html 未部署也能登录。
|
||
//
|
||
// 续期(Auth Broker 路径 A):access_token 默认 900s;引擎(WebView)持注入的 refresh_token,
|
||
// 到期经 POST /api/auth/refresh {refresh_token} 自刷(cdrop 代理 broker,见 refresh.go),broker
|
||
// 轮换 refresh 后经桥 sessionRotated 回报本端更新 Keychain;refresh 过期 / 吊销则经 authExpired
|
||
// 回登录页重扫。本端不再依赖 cdrop_session cookie。
|
||
@MainActor
|
||
@Observable
|
||
final class AuthManager
|
||
{
|
||
struct User: Codable
|
||
{
|
||
let id: String
|
||
let name: String
|
||
let avatar: String?
|
||
}
|
||
|
||
struct Session: Codable
|
||
{
|
||
let accessToken: String
|
||
// refreshToken:broker 委托会话的续期凭证,随 access 一并注入引擎自刷用;轮换后更新。
|
||
let refreshToken: String
|
||
let user: User
|
||
let deviceName: String
|
||
// deviceId:cdrop 生成的稳定不透明设备 id(= broker meta,回显 X-Auth-Meta)。
|
||
let deviceId: String
|
||
let scope: String
|
||
}
|
||
|
||
var session: Session?
|
||
var qrPayload: String?
|
||
// 空初值:登录页以 broker 账号登录为主,无操作时不显状态文案;各登录流程开始时自行设置。
|
||
var statusText: String = ""
|
||
// 二维码已失效(denied/expired/失败)→ 登录页据此提示并高亮「刷新二维码」。
|
||
var qrExpired = false
|
||
|
||
// 登录代次:每次 startQRLogin / startBrokerLogin 自增,旧流程据此识别自己已被取代、丢弃
|
||
// 结果,避免两条登录流程并发改 statusText / session 打架。
|
||
private var loginGeneration = 0
|
||
|
||
// Broker 登录期间强持 ASWebAuthenticationSession 封装(其 presentationContextProvider 为 weak、
|
||
// session 自身亦须保活至回调),完成 / 失败后置空。
|
||
private var brokerFlow: BrokerAuthFlow?
|
||
|
||
// Keychain 持久化坐标(单 app 私有,见 Keychain.swift)。
|
||
private static let keychainService = "net.commilitia.cdrop.session"
|
||
private static let keychainAccount = "session"
|
||
|
||
// 启动即尝试恢复持久会话:命中则 app 直接进主界面,免反复扫码(迭代 / 重装友好)。
|
||
// 恢复的令牌可能已过期(guest 1h 上限)——此时引擎鉴权 401,用户经设置页登出重扫即可。
|
||
init()
|
||
{
|
||
if let data = Keychain.load(service: Self.keychainService, account: Self.keychainAccount),
|
||
let restored = try? JSONDecoder().decode(Session.self, from: data)
|
||
{
|
||
// 仅恢复完整权限会话;陈旧 guest(旧版本落地的)丢弃、强制重登为 full。
|
||
if restored.scope == "full" { session = restored }
|
||
else { Keychain.delete(service: Self.keychainService, account: Self.keychainAccount) }
|
||
}
|
||
}
|
||
|
||
// 后端基址:默认线上,可经 CDROP_API_BASE 覆盖(本机测试)。
|
||
private var apiBase: String
|
||
{
|
||
ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? "https://drop.commilitia.net"
|
||
}
|
||
|
||
private struct QRStart: Decodable
|
||
{
|
||
let request_id: String
|
||
let poll_secret: String
|
||
let qr_payload: String
|
||
let expires_at: Int64
|
||
}
|
||
|
||
private struct QRStatus: Decodable
|
||
{
|
||
let status: String
|
||
let access_token: String?
|
||
let refresh_token: String?
|
||
let device_id: String?
|
||
let expires_in: Int?
|
||
let user: User?
|
||
let device_name: String?
|
||
}
|
||
|
||
// startQRLogin:发起二维码 + 轮询直到批准 / 失效。可重复调用(刷新二维码):自增代次,
|
||
// 旧轮询识别到代次变更即弃。
|
||
func startQRLogin() async
|
||
{
|
||
loginGeneration += 1
|
||
let gen = loginGeneration
|
||
qrExpired = false
|
||
qrPayload = nil
|
||
statusText = t("ios.login.generating")
|
||
do
|
||
{
|
||
let start = try await qrStart()
|
||
if gen != loginGeneration { return }
|
||
qrPayload = start.qr_payload
|
||
statusText = t("ios.login.waiting")
|
||
try await poll(requestID: start.request_id, pollSecret: start.poll_secret, gen: gen)
|
||
}
|
||
catch
|
||
{
|
||
if gen != loginGeneration { return }
|
||
statusText = t("ios.login.failed")
|
||
qrExpired = true
|
||
}
|
||
}
|
||
|
||
// 应用内 Broker 登录(device-authorization + PKCE,见 BrokerLogin.swift):手机直接输账号登录,
|
||
// 无需另一台设备扫码。流程=拉 broker 配置 → ASWebAuthenticationSession 授权拿 code → 换 bootstrap
|
||
// 令牌 → 代铸成 cdrop 设备会话(带真名 / 稳定 device_id)→ 落 full 会话 + Keychain。
|
||
func startBrokerLogin() async
|
||
{
|
||
loginGeneration += 1
|
||
let gen = loginGeneration
|
||
qrExpired = false
|
||
statusText = t("ios.login.brokerStarting")
|
||
do
|
||
{
|
||
let cfg = try await fetchAuthConfig()
|
||
guard !cfg.brokerURL.isEmpty, !cfg.brokerApp.isEmpty else { throw BrokerLoginError.incompleteConfig }
|
||
|
||
let verifier = PKCE.randomToken(64)
|
||
let challenge = PKCE.challenge(for: verifier)
|
||
let state = PKCE.randomToken(24)
|
||
let redirectURI = "cdrop://auth-callback"
|
||
|
||
var comp = URLComponents(string: cfg.brokerURL.trimmingTrailingSlash() + "/device/authorize")!
|
||
comp.queryItems = [
|
||
URLQueryItem(name: "app", value: cfg.brokerApp),
|
||
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
||
URLQueryItem(name: "state", value: state),
|
||
URLQueryItem(name: "code_challenge", value: challenge),
|
||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||
URLQueryItem(name: "description", value: "Commilitia Drop iOS"),
|
||
]
|
||
guard let authURL = comp.url else { throw BrokerLoginError.incompleteConfig }
|
||
|
||
let flow = BrokerAuthFlow()
|
||
brokerFlow = flow
|
||
let callback = try await flow.run(url: authURL, callbackScheme: "cdrop")
|
||
brokerFlow = nil
|
||
if gen != loginGeneration { return }
|
||
|
||
let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||
guard items.first(where: { $0.name == "state" })?.value == state
|
||
else { throw BrokerLoginError.stateMismatch }
|
||
guard let code = items.first(where: { $0.name == "code" })?.value, !code.isEmpty
|
||
else { throw BrokerLoginError.missingCode }
|
||
|
||
let bootstrap = try await exchangeDeviceToken(
|
||
brokerURL: cfg.brokerURL, code: code, verifier: verifier, redirectURI: redirectURI)
|
||
let ds = try await mintDeviceSession(bootstrap: bootstrap)
|
||
if gen != loginGeneration { return }
|
||
|
||
DeviceIDStore.value = ds.device_id
|
||
let realName = (ds.name?.isEmpty == false) ? ds.name! : ds.user_id
|
||
session = Session(accessToken: ds.access_token,
|
||
refreshToken: ds.refresh_token,
|
||
user: User(id: ds.user_id, name: realName, avatar: ds.avatar),
|
||
deviceName: ds.device_name ?? DeviceNameStore.value,
|
||
deviceId: ds.device_id,
|
||
scope: "full")
|
||
persist()
|
||
}
|
||
catch
|
||
{
|
||
brokerFlow = nil
|
||
if gen != loginGeneration { return }
|
||
// 用户主动取消(关闭授权页)不算失败:静默回到登录页等待再次操作。
|
||
if let asErr = error as? ASWebAuthenticationSessionError, asErr.code == .canceledLogin
|
||
{
|
||
statusText = t("ios.login.waiting")
|
||
return
|
||
}
|
||
statusText = t("ios.login.failed")
|
||
}
|
||
}
|
||
|
||
func logout()
|
||
{
|
||
session = nil
|
||
qrPayload = nil
|
||
statusText = ""
|
||
Keychain.delete(service: Self.keychainService, account: Self.keychainAccount)
|
||
}
|
||
|
||
// 静态清除持久会话:被其他设备登出的后台推送到达时(AppDelegate)无 live AuthManager 实例可调
|
||
// logout(),故提供静态版只清 Keychain——下次启动 init 读不到会话即为登出态。前台另经
|
||
// .cdropSessionRevoked 通知走实例 logout() 即时回登录页。
|
||
static func clearPersistedSession()
|
||
{
|
||
Keychain.delete(service: keychainService, account: keychainAccount)
|
||
}
|
||
|
||
// ---- Broker 登录辅助:配置拉取 / 令牌交换 / 代铸 ----
|
||
|
||
private struct AuthConfig { let brokerURL: String; let brokerApp: String }
|
||
|
||
private struct DeviceTokenResp: Decodable
|
||
{
|
||
let access: String
|
||
let refresh: String
|
||
let access_expires: Int64
|
||
}
|
||
|
||
// 代铸响应:access/refresh + 稳定 device_id + 经边缘核验的真实身份(user_id/name/avatar)。
|
||
private struct DeviceSessionResp: Decodable
|
||
{
|
||
let access_token: String
|
||
let refresh_token: String
|
||
let device_id: String
|
||
let device_name: String?
|
||
let user_id: String
|
||
let name: String?
|
||
let avatar: String?
|
||
}
|
||
|
||
private func fetchAuthConfig() async throws -> AuthConfig
|
||
{
|
||
let (data, _) = try await URLSession.shared.data(from: URL(string: "\(apiBase)/api/auth/config")!)
|
||
let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
|
||
return AuthConfig(brokerURL: obj["broker_url"] as? String ?? "",
|
||
brokerApp: obj["broker_app"] as? String ?? "")
|
||
}
|
||
|
||
private func exchangeDeviceToken(
|
||
brokerURL: String, code: String, verifier: String, redirectURI: String) async throws -> String
|
||
{
|
||
var req = URLRequest(url: URL(string: brokerURL.trimmingTrailingSlash() + "/device/token")!)
|
||
req.httpMethod = "POST"
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||
req.httpBody = try JSONSerialization.data(withJSONObject: [
|
||
"code": code, "code_verifier": verifier, "redirect_uri": redirectURI,
|
||
])
|
||
let (data, resp) = try await URLSession.shared.data(for: req)
|
||
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||
guard status == 200 else { throw BrokerLoginError.tokenFailed(status) }
|
||
let tr = try JSONDecoder().decode(DeviceTokenResp.self, from: data)
|
||
guard !tr.access.isEmpty else { throw BrokerLoginError.badResponse }
|
||
return tr.access
|
||
}
|
||
|
||
private func mintDeviceSession(bootstrap: String) async throws -> DeviceSessionResp
|
||
{
|
||
var req = URLRequest(url: URL(string: "\(apiBase)/api/auth/device-session")!)
|
||
req.httpMethod = "POST"
|
||
req.setValue("Bearer \(bootstrap)", forHTTPHeaderField: "Authorization")
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
req.setValue("ios", forHTTPHeaderField: "X-Device-Type")
|
||
req.httpBody = try JSONSerialization.data(withJSONObject: [
|
||
"device_id": DeviceIDStore.value,
|
||
"device_name": DeviceNameStore.value,
|
||
"device_type": "ios",
|
||
])
|
||
let (data, resp) = try await URLSession.shared.data(for: req)
|
||
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||
guard status == 200 else { throw BrokerLoginError.deviceSessionFailed(status) }
|
||
return try JSONDecoder().decode(DeviceSessionResp.self, from: data)
|
||
}
|
||
|
||
// 持久化当前会话到 Keychain(扫码批准后调用)。
|
||
private func persist()
|
||
{
|
||
guard let session, let data = try? JSONEncoder().encode(session) else { return }
|
||
Keychain.save(data, service: Self.keychainService, account: Self.keychainAccount)
|
||
}
|
||
|
||
// updateSession:引擎自刷致 broker 轮换后,经桥 sessionRotated 回报的新令牌对 → 更新
|
||
// 内存会话 + Keychain,使重启后不再用已失效的旧 refresh。仅换令牌,身份 / 设备名不变。
|
||
func updateSession(accessToken: String, refreshToken: String)
|
||
{
|
||
guard let cur = session else { return }
|
||
session = Session(accessToken: accessToken, refreshToken: refreshToken,
|
||
user: cur.user, deviceName: cur.deviceName,
|
||
deviceId: cur.deviceId, scope: cur.scope)
|
||
persist()
|
||
}
|
||
|
||
// updateIdentity:引擎据 /api/me 取到真实显示名(QR 登录注入的是 subject UUID 名)→ 经桥
|
||
// identityUpdated 回报,更新会话身份 + Keychain,使设置页显示真名而非 UUID(#12)。仅换
|
||
// 显示身份,令牌 / 设备名不变。
|
||
func updateIdentity(name: String, avatar: String?)
|
||
{
|
||
guard let cur = session else { return }
|
||
let av = (avatar?.isEmpty == false) ? avatar : cur.user.avatar
|
||
session = Session(accessToken: cur.accessToken, refreshToken: cur.refreshToken,
|
||
user: User(id: cur.user.id, name: name, avatar: av),
|
||
deviceName: cur.deviceName, deviceId: cur.deviceId, scope: cur.scope)
|
||
persist()
|
||
}
|
||
|
||
// updateDeviceName:本机即时改名成功(PATCH /api/devices/{device_id})后经桥 renamed 回报,
|
||
// 更新会话里的设备名 + Keychain,使重启后 boot 注入新名。令牌 / 身份不变——设备名与令牌解耦。
|
||
func updateDeviceName(_ name: String)
|
||
{
|
||
guard let cur = session else { return }
|
||
session = Session(accessToken: cur.accessToken, refreshToken: cur.refreshToken,
|
||
user: cur.user, deviceName: name,
|
||
deviceId: cur.deviceId, scope: cur.scope)
|
||
persist()
|
||
}
|
||
|
||
// 仅供本机模拟器联调(CDROP_DEBUG_SESSION=1):注入占位会话跳过扫码,用于验证引擎从
|
||
// prod 加载 + 桥往返。令牌无效、鉴权会 401,但能证 engine.html 在 WKWebView 里加载运行。
|
||
func debugSession()
|
||
{
|
||
session = Session(accessToken: "debug", refreshToken: "",
|
||
user: User(id: "debug", name: "Simulator", avatar: nil),
|
||
deviceName: "Simulator", deviceId: "", scope: "guest")
|
||
}
|
||
|
||
private func qrStart() async throws -> QRStart
|
||
{
|
||
var req = URLRequest(url: URL(string: "\(apiBase)/api/auth/qr/start")!)
|
||
req.httpMethod = "POST"
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
req.setValue("ios", forHTTPHeaderField: "X-Device-Type")
|
||
req.httpBody = try JSONSerialization.data(
|
||
withJSONObject: [ "device_name": deviceName(), "device_type": "ios" ])
|
||
let (data, _) = try await URLSession.shared.data(for: req)
|
||
return try JSONDecoder().decode(QRStart.self, from: data)
|
||
}
|
||
|
||
private func poll(requestID: String, pollSecret: String, gen: Int) async throws
|
||
{
|
||
while session == nil && gen == loginGeneration
|
||
{
|
||
var comp = URLComponents(string: "\(apiBase)/api/auth/qr/status")!
|
||
comp.queryItems = [ URLQueryItem(name: "request_id", value: requestID) ]
|
||
var req = URLRequest(url: comp.url!)
|
||
req.setValue(pollSecret, forHTTPHeaderField: "X-Poll-Secret")
|
||
let (data, _) = try await URLSession.shared.data(for: req)
|
||
if gen != loginGeneration { return } // 已被刷新取代,丢弃本轮结果
|
||
let st = try JSONDecoder().decode(QRStatus.self, from: data)
|
||
switch st.status
|
||
{
|
||
case "approved":
|
||
if let token = st.access_token, let user = st.user
|
||
{
|
||
// 原生 App 仅允许完整权限(用户原则:iOS/桌面只接受 full,受限访客是 Web/PWA
|
||
// 的权宜)。批准方若按「仅此次」授予 guest,这里据 /api/me 的真实 scope 拒绝、
|
||
// 提示改选「信任此设备」,并让二维码可刷新重扫——不落地 guest 会话。
|
||
let scope = await fetchScope(token: token)
|
||
if gen != loginGeneration { return }
|
||
if scope != "full"
|
||
{
|
||
statusText = t("ios.login.needFull")
|
||
qrExpired = true
|
||
return
|
||
}
|
||
session = Session(accessToken: token,
|
||
refreshToken: st.refresh_token ?? "",
|
||
user: user,
|
||
deviceName: st.device_name ?? deviceName(),
|
||
deviceId: st.device_id ?? "",
|
||
scope: "full")
|
||
persist()
|
||
}
|
||
return
|
||
case "denied", "expired":
|
||
statusText = t("ios.login.expired")
|
||
qrExpired = true
|
||
return
|
||
default:
|
||
continue // pending → 立即下一轮(服务端长轮询最多挂 25s)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 据刚发的 access token 拉 /api/me 取真实会话级别(full / guest)。失败回空串(按非 full
|
||
// 处理,保守拒绝)。
|
||
private func fetchScope(token: String) async -> String
|
||
{
|
||
var req = URLRequest(url: URL(string: "\(apiBase)/api/me")!)
|
||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||
guard let (data, _) = try? await URLSession.shared.data(for: req),
|
||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
let scope = obj["scope"] as? String
|
||
else { return "" }
|
||
return scope
|
||
}
|
||
|
||
private func deviceName() -> String
|
||
{
|
||
return DeviceNameStore.value
|
||
}
|
||
}
|