127070e226
设备名与令牌解耦(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)
392 lines
17 KiB
Swift
392 lines
17 KiB
Swift
import AuthenticationServices
|
||
import Foundation
|
||
import Observation
|
||
import UIKit
|
||
|
||
// 扫码登录(新设备侧)+ 会话持有。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)
|
||
}
|
||
|
||
// ---- 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
|
||
}
|
||
}
|