Files
Commilitia-Drop/ios/CDrop/Sources/ScannerView.swift
T
admin 1b94df1604 iOS 第四批反馈:隔夜重登修复 + 通知/红点/日志/扫码登他端/主屏小组件 + 长条行交互统一
- #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。
2026-06-27 15:53:54 +08:00

166 lines
5.5 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 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
// niltrue/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
}
}
}
}
}