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
+148 -76
View File
@@ -20,6 +20,8 @@ struct RootView: View
{
TabView(selection: $selection)
{
// #1
// / #5
Tab(t("ios.tab.transfer"), systemImage: "arrow.up.arrow.down", value: "transfer")
{
NavigationStack
@@ -27,11 +29,12 @@ struct RootView: View
TransferListView()
}
}
Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices")
.badge(engine.unreadTransfers)
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
{
NavigationStack
{
DeviceListView()
ReceivedFilesView()
}
}
Tab(t("ios.tab.messages"), systemImage: "message", value: "messages")
@@ -41,11 +44,12 @@ struct RootView: View
MessagesView()
}
}
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
.badge(engine.unreadMessages)
Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices")
{
NavigationStack
{
ReceivedFilesView()
DeviceListView()
}
}
Tab(t("ios.tab.settings"), systemImage: "gearshape", value: "settings")
@@ -58,6 +62,13 @@ struct RootView: View
}
.tabBarMinimizeBehavior(.onScrollDown)
.tint(.cdropAccent)
// #2
.overlay(alignment: .top)
{
NoticeOverlay()
}
// #5
.onChange(of: selection) { engine.setActiveTab(selection) }
.background
{
EngineWebView(controller: engine, auth: auth)
@@ -95,6 +106,9 @@ struct TransferListView: View
@State private var showDevicePicker = false
@State private var showNoDevices = false
@State private var showClearConfirm = false
// List NavigationLink chevron
// navigationDestination(item:) sessionIdString Hashable
@State private var detailSession: String?
// 线 EngineController.sendableDevices
private var sendableDevices: [DeviceItem]
@@ -104,44 +118,19 @@ struct TransferListView: View
var body: some View
{
ScrollView
Group
{
if engine.transfers.isEmpty && engine.history.isEmpty
{
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
.padding(.top, 80)
}
else
{
VStack(spacing: 16)
{
if !engine.transfers.isEmpty
{
sectionHeader(t("home.transfer.active"))
ForEach(engine.transfers)
{ item in
transferLink(item)
}
}
if !engine.history.isEmpty
{
sectionHeader(t("home.transfer.history"))
ForEach(engine.history)
{ item in
transferLink(item)
.contextMenu
{
Button(role: .destructive)
{ engine.deleteTransferRecord(item.sessionId) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
}
}
}
.padding()
transferList
}
}
.navigationTitle(t("ios.tab.transfer"))
.navigationDestination(item: $detailSession) { sid in TransferDetailView(sessionId: sid) }
.toolbar
{
if !engine.history.isEmpty
@@ -201,6 +190,32 @@ struct TransferListView: View
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
}
// List ScrollView / +
// .plain + + 线 body
private var transferList: some View
{
List
{
if !engine.transfers.isEmpty
{
Section
{
ForEach(engine.transfers) { transferRow($0, history: false) }
}
header: { sectionHeader(t("home.transfer.active")) }
}
if !engine.history.isEmpty
{
Section
{
ForEach(engine.history) { transferRow($0, history: true) }
}
header: { sectionHeader(t("home.transfer.history")) }
}
}
.listStyle(.plain)
}
private func sectionHeader(_ title: String) -> some View
{
HStack
@@ -212,17 +227,36 @@ struct TransferListView: View
}
}
private func transferLink(_ item: TransferItem) -> some View
// + /
// /
//
@ViewBuilder
private func transferRow(_ item: TransferItem, history: Bool) -> some View
{
NavigationLink
let card = TransferCardView(item: item)
.contentShape(Rectangle())
.onTapGesture { detailSession = item.sessionId }
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
if history
{
TransferDetailView(sessionId: item.sessionId)
card
.swipeActions(edge: .trailing)
{
Button(role: .destructive) { engine.deleteTransferRecord(item.sessionId) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
.contextMenu
{
Button(role: .destructive) { engine.deleteTransferRecord(item.sessionId) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
}
label:
else
{
TransferCardView(item: item)
card
}
.buttonStyle(.plain)
}
private func startSend(to device: String)
@@ -424,6 +458,7 @@ struct DeviceListView: View
@Environment(EngineController.self) private var engine
@State private var revokeTarget: DeviceItem?
@State private var showRevoke = false
@State private var showScanner = false
//
private var allDevices: [DeviceItem]
@@ -451,18 +486,24 @@ struct DeviceListView: View
{
List
{
if !engine.deviceActionStatus.isEmpty
{
Text(engine.deviceActionStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(allDevices)
{ dev in
deviceRow(dev)
.contentShape(Rectangle())
.onTapGesture { if !isSelf(dev) { revokeTarget = dev; showRevoke = true } }
// +
.swipeActions(edge: .trailing)
{
if !isSelf(dev)
{
// destructive
// +
// 线
Button { revokeTarget = dev; showRevoke = true }
label: { Label(t("ios.devices.remove"), systemImage: "trash") }
.tint(.red)
}
}
.contextMenu
{
if !isSelf(dev)
{
@@ -475,6 +516,19 @@ struct DeviceListView: View
}
}
.navigationTitle(t("ios.tab.devices"))
.toolbar
{
// #8
ToolbarItem(placement: .topBarTrailing)
{
Button { showScanner = true }
label: { Label(t("ios.scan.entry"), systemImage: "qrcode.viewfinder") }
}
}
.sheet(isPresented: $showScanner)
{
ScanSheet { requestId, code in engine.fetchLoginRequest(requestId: requestId, code: code) }
}
.confirmationDialog(revokeTarget?.name ?? "", isPresented: $showRevoke, titleVisibility: .visible)
{
Button(t("ios.devices.remove"), role: .destructive)
@@ -483,6 +537,40 @@ struct DeviceListView: View
}
Button(t("common.cancel"), role: .cancel) { }
}
// #8 / / IP
.confirmationDialog(
t("ios.scan.approveTitle"),
isPresented: Binding(
get: { engine.pendingLoginRequest != nil },
set: { if !$0 { engine.cancelLoginRequest() } }),
titleVisibility: .visible)
{
Button(t("ios.scan.approve")) { engine.approveLogin() }
Button(t("ios.scan.deny"), role: .destructive) { engine.denyLogin() }
Button(t("common.cancel"), role: .cancel) { engine.cancelLoginRequest() }
}
message:
{
if let req = engine.pendingLoginRequest
{
Text(loginRequestInfo(req))
}
}
}
// · IP
private func loginRequestInfo(_ req: LoginRequestItem) -> String
{
var head: [String] = []
if !req.deviceName.isEmpty { head.append(req.deviceName) }
if !req.deviceType.isEmpty { head.append(req.deviceType) }
var s = head.joined(separator: " · ")
if !req.requestIp.isEmpty
{
if !s.isEmpty { s += "\n" }
s += "\(t("ios.scan.requestIp")): \(req.requestIp)"
}
return s
}
private func deviceRow(_ dev: DeviceItem) -> some View
@@ -537,12 +625,6 @@ struct SettingsView: View
TextField(t("ios.settings.deviceName"), text: $deviceNameDraft)
.submitLabel(.done)
.onSubmit { commitRename() }
if !engine.deviceActionStatus.isEmpty
{
Text(engine.deviceActionStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
Button(role: .destructive) { logout() }
label:
{
@@ -566,17 +648,23 @@ struct SettingsView: View
LabeledContent(t("ios.settings.signaling"), value: signalingState)
LabeledContent(t("ios.settings.presenceEvents"), value: "\(engine.presenceCount)")
LabeledContent(t("ios.settings.deviceCount"), value: "\(engine.devices.count)")
if !engine.lastEngineLog.isEmpty
// #3 N
NavigationLink
{
LogView()
}
label:
{
VStack(alignment: .leading, spacing: 2)
{
Text(t("ios.settings.lastLog"))
.font(.caption)
.foregroundStyle(.secondary)
Text(engine.lastEngineLog)
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
Text(t("ios.settings.viewLogs"))
if !engine.lastEngineLog.isEmpty
{
Text(engine.lastEngineLog)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
@@ -586,12 +674,6 @@ struct SettingsView: View
label: { Label(t("ios.clipboard.upload"), systemImage: "arrow.up.doc.on.clipboard") }
Button { engine.pullClipboard() }
label: { Label(t("ios.clipboard.pull"), systemImage: "arrow.down.doc") }
if !engine.clipboardStatus.isEmpty
{
Text(engine.clipboardStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
}
header:
{
@@ -800,14 +882,4 @@ private func deviceSymbol(_ type: String) -> String
return "desktopcomputer"
}
// web accent #644AC9 / #9580FF Asset
// Catalog Color Set UIColor
extension Color
{
static let cdropAccent = Color(uiColor: UIColor
{ trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.584, green: 0.502, blue: 1.0, alpha: 1)
: UIColor(red: 0.392, green: 0.290, blue: 0.788, alpha: 1)
})
}
// Color.cdropAccent Shared/Theme.swift app /