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 需账号交互)
120 lines
4.6 KiB
Swift
120 lines
4.6 KiB
Swift
import AuthenticationServices
|
||
import CryptoKit
|
||
import Foundation
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#elseif canImport(AppKit)
|
||
import AppKit
|
||
#endif
|
||
|
||
// 应用内 Broker 登录(device-authorization + PKCE,对齐桌面 desktop/platform/oauth.go)。iOS 无法
|
||
// 跑 loopback 监听,故用 ASWebAuthenticationSession + 自定义 scheme 回调(cdrop://auth-callback):
|
||
// 1. GET {broker}/device/authorize?app=cdrop&redirect_uri=cdrop://auth-callback&state&code_challenge&S256
|
||
// 2. 用户在 broker SSO 授权 → 回调 cdrop://auth-callback?code=..&state=..
|
||
// 3. POST {broker}/device/token {code, code_verifier, redirect_uri} → bootstrap 机器令牌
|
||
// 4. POST {api}/api/auth/device-session(Bearer bootstrap)→ cdrop 设备会话(带真名 / device_id)
|
||
// 这样手机上直接输账号登录,无需另一台设备扫码批准。
|
||
// 前置:broker 须为 cdrop 应用注册 redirect_uri cdrop://auth-callback(同桌面 loopback 须注册)。
|
||
|
||
enum BrokerLoginError: Error
|
||
{
|
||
case incompleteConfig
|
||
case cannotStart
|
||
case noCallback
|
||
case stateMismatch
|
||
case missingCode
|
||
case tokenFailed(Int)
|
||
case deviceSessionFailed(Int)
|
||
case badResponse
|
||
}
|
||
|
||
// ASWebAuthenticationSession 的 async 封装 + 展示锚点提供者。须被持有至完成(session 的
|
||
// presentationContextProvider 是 weak,且 session 自身也须保活),故由 AuthManager 在登录期间强持。
|
||
@MainActor
|
||
final class BrokerAuthFlow: NSObject, ASWebAuthenticationPresentationContextProviding
|
||
{
|
||
private var session: ASWebAuthenticationSession?
|
||
|
||
// 打开授权页,等回调 URL(cdrop://auth-callback?code=..&state=..)。用户取消 / 失败抛错。
|
||
func run(url: URL, callbackScheme: String) async throws -> URL
|
||
{
|
||
try await withCheckedThrowingContinuation
|
||
{ cont in
|
||
let s = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme)
|
||
{ [weak self] callback, error in
|
||
self?.session = nil
|
||
if let error { cont.resume(throwing: error) }
|
||
else if let callback { cont.resume(returning: callback) }
|
||
else { cont.resume(throwing: BrokerLoginError.noCallback) }
|
||
}
|
||
s.presentationContextProvider = self
|
||
// 共享浏览会话:若用户已在 Safari 登录 broker SSO,则免再输密码(无缝 SSO)。
|
||
s.prefersEphemeralWebBrowserSession = false
|
||
self.session = s
|
||
if !s.start() { cont.resume(throwing: BrokerLoginError.cannotStart) }
|
||
}
|
||
}
|
||
|
||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor
|
||
{
|
||
#if os(iOS)
|
||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||
return scenes.flatMap { $0.windows }.first { $0.isKeyWindow }
|
||
?? scenes.first?.windows.first
|
||
?? ASPresentationAnchor()
|
||
#else
|
||
return NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first ?? ASPresentationAnchor()
|
||
#endif
|
||
}
|
||
}
|
||
|
||
// 跨登录持久的稳定 device_id(broker meta / 会话↔设备连接键)。存 UserDefaults,登出不清——
|
||
// 故重登复用同一 device_id,broker R2 幂等轮换同一会话、设备表 UPSERT 同一行,杜绝重复设备项
|
||
// (对齐 web 的 localStorage cdrop.device_id)。首次为空,由后端铸发后回存。
|
||
enum DeviceIDStore
|
||
{
|
||
private static let key = "cdrop.device_id"
|
||
static var value: String
|
||
{
|
||
get { UserDefaults.standard.string(forKey: key) ?? "" }
|
||
set { UserDefaults.standard.set(newValue, forKey: key) }
|
||
}
|
||
}
|
||
|
||
// PKCE / 随机串(对齐桌面 randString:n 字节 base64url 取前 n 字符,落在 PKCE unreserved 集内)。
|
||
enum PKCE
|
||
{
|
||
static func randomToken(_ n: Int) -> String
|
||
{
|
||
var bytes = [UInt8](repeating: 0, count: n)
|
||
_ = SecRandomCopyBytes(kSecRandomDefault, n, &bytes)
|
||
return String(Data(bytes).base64URLEncoded().prefix(n))
|
||
}
|
||
|
||
static func challenge(for verifier: String) -> String
|
||
{
|
||
let digest = SHA256.hash(data: Data(verifier.utf8))
|
||
return Data(digest).base64URLEncoded()
|
||
}
|
||
}
|
||
|
||
extension Data
|
||
{
|
||
// base64url(无填充,- / _ 替 + /),用于 PKCE challenge / 随机串。
|
||
func base64URLEncoded() -> String
|
||
{
|
||
base64EncodedString()
|
||
.replacingOccurrences(of: "+", with: "-")
|
||
.replacingOccurrences(of: "/", with: "_")
|
||
.replacingOccurrences(of: "=", with: "")
|
||
}
|
||
}
|
||
|
||
extension String
|
||
{
|
||
func trimmingTrailingSlash() -> String
|
||
{
|
||
hasSuffix("/") ? String(dropLast()) : self
|
||
}
|
||
}
|