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。
This commit is contained in:
2026-06-27 15:53:54 +08:00
parent 127070e226
commit 1b94df1604
18 changed files with 1038 additions and 103 deletions
+165
View File
@@ -0,0 +1,165 @@
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
}
}
}
}
}