1b94df1604
- #7 隔夜被迫重新登录:auth.ts 加 refreshInFlight 单飞刷新。根因=broker 单次轮换 refresh token + 冷启并发 401(引擎 boot 同时 startHub/refreshSessionScope/healIOSIdentity/refreshICEServers,隔夜 access 已过期全部 401)各自用同一 refresh 刷新,首个轮换、其余 replay 失效旧 token → 401 invalid → forceLogout → 清 Keychain → 重登。单飞使并发 401 共享一次轮换。 - #2 通知系统重做:删 EngineController.deviceActionStatus/clipboardStatus 两字段及设备页/设置页内联灰字(串页、不清除);改瞬时横幅 NotificationBanner(NoticeOverlay 顶部覆盖,成功绿/失败红/信息中性、4s 自动消失、可点叉清除),设备移除/改名/剪贴板结果统一走 notify。 - #5 未读红点:EngineController unreadMessages/unreadTransfers/activeTab/seenIncomingTransfers + setActiveTab(进入对应 tab 清零)+ noteIncomingTransfer;RootView Tab .badge + onChange(selection) 同步当前页。 - #1 标签栏重排:传输 / 文件 / 消息 / 设备 / 设置(文件域相邻、设备归管理区近设置)。 - #3 日志独立界面:EngineController logs 环形缓冲(cap 300)+ clearLogs;新增 LogView(最近 N 条、分级配色、可选中、可清空),设置页引擎段「查看日志」NavigationLink。 - #8 扫码登录他端:iOS 此前仅能展示自身码被扫,缺扫描方批准能力。新增 ScannerView(VisionKit DataScannerViewController + 相机权限 + /link?r=&c= 校验);engine/main.ts 导入 qr.ts 加 fetchLoginRequest/approveLogin/denyLogin(qrRequest/qrApprove(full,persist)/qrDeny)+ 回报 loginRequest/loginApproved/loginDenied/loginFailed;EngineController pendingLoginRequest + 方法 + handleNotify;设备页工具栏「扫码登录设备」入口 + 批准确认(展示对端名/类型/IP)。 - #6 主屏小组件 + 应用外反馈:除控制中心两控件外,新增 CloudClipboardWidget(交互式主屏/锁屏小组件,上传/拉取按钮复用同一 AppIntent);新增 WidgetFeedback(App 外触发弹本地通知反馈);ClipboardIntents perform 加成功/失败/空/needApp 反馈。品牌色 Color.cdropAccent 从 RootView 移入 Shared/Theme(主 app 与扩展共用)。 - 长条行交互统一:设备 / 传输历史 / 收到的文件 一致为「点按打开 + 尾部滑动删除 + 长按菜单」。设备去掉「点按即删」、加长按移除;修滑动移除时「析构按钮预演删除动画致下方设备上移又复位」的抖动(改普通按钮 + 红色,真正删除在二次确认后);传输页 ScrollView→List 以获原生滑动删除,卡片观感经 .plain + 透明行背景 + 隐藏分隔线保留。 - i18n:3 locale 新增 ios.logs./ios.scan./ios.widget./ios.settings.viewLogs 等键并重生 iOS JSON。
166 lines
5.5 KiB
Swift
166 lines
5.5 KiB
Swift
import AVFoundation
|
||
import SwiftUI
|
||
import VisionKit
|
||
|
||
// 扫码登录他端(#8):本机已登录,用相机扫另一台设备显示的登录二维码(/link?r=&c=),解析校验后把
|
||
// requestId / code 交引擎走批准流程。用 VisionKit 的 DataScannerViewController(系统级扫码 UI,免手搓
|
||
// AVFoundation 会话)。模拟器无相机 → isSupported 为假,回落「相机不可用」提示。
|
||
struct ScanSheet: View
|
||
{
|
||
// 扫到合法登录二维码时回调 (requestId, code),随后自动关闭扫码页。
|
||
let onScanned: (String, String) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
// nil=权限询问中;true/false=已授权 / 被拒。
|
||
@State private var authorized: Bool?
|
||
@State private var invalid = false
|
||
|
||
var body: some View
|
||
{
|
||
NavigationStack
|
||
{
|
||
content
|
||
.navigationTitle(t("ios.scan.title"))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar
|
||
{
|
||
ToolbarItem(placement: .topBarLeading)
|
||
{
|
||
Button(t("common.cancel")) { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
.task { await requestCamera() }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View
|
||
{
|
||
if authorized == nil
|
||
{
|
||
ProgressView()
|
||
}
|
||
else if authorized == true, DataScannerViewController.isSupported
|
||
{
|
||
scanner
|
||
}
|
||
else
|
||
{
|
||
ContentUnavailableView(t("ios.scan.cameraDenied"), systemImage: "camera.fill")
|
||
}
|
||
}
|
||
|
||
private var scanner: some View
|
||
{
|
||
ZStack(alignment: .bottom)
|
||
{
|
||
LoginScannerView(onScan: handleScan)
|
||
VStack(spacing: 8)
|
||
{
|
||
if invalid
|
||
{
|
||
Text(t("ios.scan.invalid"))
|
||
.font(.callout)
|
||
.padding(.vertical, 8)
|
||
.padding(.horizontal, 14)
|
||
.background(.red.opacity(0.85), in: Capsule())
|
||
.foregroundStyle(.white)
|
||
}
|
||
Text(t("ios.scan.hint"))
|
||
.font(.footnote)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.vertical, 8)
|
||
.padding(.horizontal, 14)
|
||
.background(.black.opacity(0.5), in: Capsule())
|
||
.foregroundStyle(.white)
|
||
}
|
||
.padding(.bottom, 40)
|
||
}
|
||
.ignoresSafeArea(edges: .bottom)
|
||
}
|
||
|
||
private func requestCamera() async
|
||
{
|
||
switch AVCaptureDevice.authorizationStatus(for: .video)
|
||
{
|
||
case .authorized:
|
||
authorized = true
|
||
case .notDetermined:
|
||
authorized = await AVCaptureDevice.requestAccess(for: .video)
|
||
default:
|
||
authorized = false
|
||
}
|
||
}
|
||
|
||
// 扫到一个码:解析校验;合法即回调并关闭,非法则提示「无法识别」并继续扫描。
|
||
private func handleScan(_ raw: String)
|
||
{
|
||
guard let parsed = parseLinkApprovalUrl(raw) else
|
||
{
|
||
invalid = true
|
||
return
|
||
}
|
||
onScanned(parsed.requestId, parsed.code)
|
||
dismiss()
|
||
}
|
||
}
|
||
|
||
// 解析扫到的登录二维码(/link?r=&c=):仅校验路径为 /link 且 r/c 非空。origin 不强校验——r/c 一律
|
||
// 发往本端自己的后端,外站码只会 404(无开放重定向风险),对齐 web 桌面侧的放宽逻辑(见 net/qr.ts)。
|
||
private func parseLinkApprovalUrl(_ raw: String) -> (requestId: String, code: String)?
|
||
{
|
||
guard let comp = URLComponents(string: raw), comp.path == "/link" else { return nil }
|
||
let items = comp.queryItems ?? []
|
||
guard let r = items.first(where: { $0.name == "r" })?.value, !r.isEmpty,
|
||
let c = items.first(where: { $0.name == "c" })?.value, !c.isEmpty
|
||
else { return nil }
|
||
return (r, c)
|
||
}
|
||
|
||
// DataScannerViewController 的 SwiftUI 封装:识别 QR 码,首个命中即回调(done 闸门避免重复触发)。
|
||
private struct LoginScannerView: UIViewControllerRepresentable
|
||
{
|
||
let onScan: (String) -> Void
|
||
|
||
func makeUIViewController(context: Context) -> DataScannerViewController
|
||
{
|
||
let scanner = DataScannerViewController(
|
||
recognizedDataTypes: [ .barcode(symbologies: [ .qr ]) ],
|
||
qualityLevel: .balanced,
|
||
isHighFrameRateTrackingEnabled: false,
|
||
isHighlightingEnabled: true)
|
||
scanner.delegate = context.coordinator
|
||
return scanner
|
||
}
|
||
|
||
func updateUIViewController(_ vc: DataScannerViewController, context: Context)
|
||
{
|
||
try? vc.startScanning()
|
||
}
|
||
|
||
func makeCoordinator() -> Coordinator { Coordinator(onScan: onScan) }
|
||
|
||
final class Coordinator: NSObject, DataScannerViewControllerDelegate
|
||
{
|
||
private let onScan: (String) -> Void
|
||
private var done = false
|
||
|
||
init(onScan: @escaping (String) -> Void) { self.onScan = onScan }
|
||
|
||
func dataScanner(_ scanner: DataScannerViewController,
|
||
didAdd addedItems: [RecognizedItem],
|
||
allItems: [RecognizedItem])
|
||
{
|
||
guard !done else { return }
|
||
for item in addedItems
|
||
{
|
||
if case let .barcode(barcode) = item, let str = barcode.payloadStringValue
|
||
{
|
||
done = true
|
||
onScan(str)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|