Files
Commilitia-Drop/ios/CDrop/Sources/Auth/AuthManager.swift
T
admin 127070e226 设备名与令牌解耦 + iOS Broker 应用内登录 / 消息 / 图库 / 记录持久化 + 三端 bug 修复
设备名与令牌解耦(presence 按 device_id 键控):
- hub 主键改稳定 device_id(presence / online / kick 用之),peer 路由仍按设备名经 hub 内 clientFor 解析——不动传输核心;Client 加 Name + 连接键,PresenceDevice 加 device_id,sse 传 claims.DeviceID,publishPresence 按 device_id 判在线、name 取自 devices 表
- 改名走已有 PATCH /api/devices/{device_id}(不重铸会话、不换 token、不中断 SSE)+ hub.Rename 更新活跃连接名 + 重广播 presence,全端即时无重复项;sessions 列表 name 用 devices 表覆盖 broker Label;web 改名从重铸迁到 PATCH,iOS 设置页即时改名;DeviceItem / DeviceInfo 按 device_id 键控判本机
- 移除「需移除两次」:publishPresence 不再把「有 device_id 但无 devices 行」的 live 连接当在线(吊销后用未过期 token 重连的僵尸),只显示 code-less 连接;合法重登新建行故正常
- revoke 对无 broker 会话的本地行也清(清掉合成测试 / 幽灵设备);跨端吊销 / 断连一律按 device_id Kick

iOS Broker 应用内登录(免扫码):
- BrokerLogin.swift:ASWebAuthenticationSession 跑 broker /device/authorize + /device/token 的 PKCE 设备授权流(对齐桌面 loopback,自定义 scheme cdrop://auth-callback 回调)→ /api/auth/device-session 代铸出带真名 + 稳定 device_id 的设备会话;DeviceIDStore 跨登录复用 device_id 杜绝重登重复
- LoginView 主登录改 broker 账号登录、扫码降级为可展开备选

iOS 功能补全:
- 设备间消息(引擎 main.ts 单条增量过桥 + onNativeEvent sendMessage;EngineController 消息状态 + 新 MessagesView 会话 UI;后端 /api/message 已就绪)
- 图库发送(PhotosPicker 文件 / 图库菜单 → stagePhotoData → 复用 cdrop-file 发送链路;NSPhotoLibraryUsageDescription)
- 传输历史 / 消息记录持久化(RecordsStore 落 Application Support JSON,跨重启存活)+ 长按删除 + 清空(二次确认)
- 进行中传输主动取消 / 立即切中继(详情页两按钮,复用引擎桥命令)
- 身份愈合(引擎 /api/me 取真实显示名经 identityUpdated 回报 Keychain,修扫码登录「用户显示 UUID」)
- 触发本地网络权限(NWBrowser 浏览 _cdrop._tcp)使 WKWebView WebRTC 取 host 候选

iOS 发送 / 启动修复:
- 发送在读文件即失败:引擎在 https 源下 fetch("cdrop-file://") 跨源 + Range 头触发 CORS 预检被拦 → 改走原生桥 bridgeFileSource / readFileSlice(512 KiB 分块 base64),绕开 CORS、整文件仍不整体进内存
- 冷启重开「missing injected session」:EngineWebView.makeUIView 在 boot 注入前确定性接好 auth,消除注入竞态

桌面 bug 修复:
- 登出静默失败:window.confirm 在 Wails WebView 失效(返回 falsy 致动作被静默跳过)→ 跨平台 DOM 确认弹窗(utils/confirm 的 Promise 式 confirmDialog + ui/ConfirmHost 的 Mantine Modal)取代之;登出改 await 先吊销 broker 会话再清本地(桌面 clearDesktopSession 会丢 Go 侧 refresh 的竞态)
- 扫 iOS 登录二维码被拒「不是本站的码」:wails:// 源永不等 https 站点源 → 桌面放宽 parseLinkApprovalUrl 的 origin 等值校验(仍留 /link 路径 + r/c;批准 r/c 一律发往本端后端、外站码只 404,无开放重定向)
- macOS 本地网络权限触发(localnetwork_darwin NWBrowser + Info.plist 键);相机修复(NSCameraUsageDescription)

web / 跨端:
- presence 透传 device_id + 离线检测按 device_id;会话列表随 presence 防抖重拉(已登出设备自动从列表消失,不再需手动刷新);移除设备 / 清剪贴板 / 撤销令牌 / 登出均改 DOM 确认弹窗

分发 / 签名:
- 真机签名从手动 scaffold 改自动 provisioning(删 Signing.xcconfig,project.yml 去 profile specifier,just ios-device 用 -allowProvisioningUpdates + ASC 团队 key);App Group 改名 group.net.commilitia.Commilitia-Drop(含 entitlements)
- hub 测试加 device_id 键控 + 改名解耦回归(-race)
2026-06-27 03:50:04 +08:00

392 lines
17 KiB
Swift
Raw 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 AuthenticationServices
import Foundation
import Observation
import UIKit
// + iOS web /
// /api/auth/qr/status / internal/httpapi/
// qr.go REST WebView engine.html
//
// Auth Broker Aaccess_token 900sWebView refresh_token
// POST /api/auth/refresh {refresh_token} cdrop broker refresh.gobroker
// refresh sessionRotated Keychainrefresh / 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
// refreshTokenbroker access
let refreshToken: String
let user: User
let deviceName: String
// deviceIdcdrop 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)
}
// ---- 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()
}
// updateDeviceNamePATCH /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
}
}