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
+168 -16
View File
@@ -50,6 +50,38 @@ struct TransferItem: Identifiable, Equatable, Codable
var id: String { sessionId }
}
// #2 / /
// / deviceActionStatus / clipboardStatus
struct Notice: Identifiable, Equatable
{
enum Kind: Equatable { case success, error, info }
let id = UUID()
let kind: Kind
let text: String
}
// #3installLogBridge console.warn/error
// N lastEngineLogat
struct LogEntry: Identifiable, Equatable
{
let id = UUID()
let level: String // warn | error | info
let message: String
let at: Date
}
// #8/link?r=&c=
// / requestId / code
struct LoginRequestItem: Identifiable, Equatable
{
let requestId: String
let code: String
let deviceName: String
let deviceType: String
let requestIp: String
var id: String { requestId }
}
// JS web/src/net/ios.tsios/PLAN.md §2
// "cdropEngine" WKScriptMessageHandler { id, method, payload }RPC
// { notify, payload }RPC evaluateJavaScript
@@ -75,14 +107,28 @@ final class EngineController: NSObject
var presenceCount = 0
var lastEngineLog = ""
// #3log logsCap N
var logs: [LogEntry] = []
private static let logsCap = 300
// #2
var notices: [Notice] = []
// #8 / /
var pendingLoginRequest: LoginRequestItem?
// #5 / tab activeTab
// 使seenIncomingTransfers
//
var unreadMessages = 0
var unreadTransfers = 0
var activeTab = "transfer"
private var seenIncomingTransfers: Set<String> = []
// JS "ready" APNs
// didRegister ready flushPushRegistration
private var engineReady = false
// / /
var clipboardStatus = ""
var deviceActionStatus = ""
// WebRTC origin PLAN R-iOS-3线
// https CDROP_ENGINE_URL http://localhostlocalhost
// WebRTC Windows loopback
@@ -175,6 +221,14 @@ final class EngineController: NSObject
transfers = []
history = []
messages = []
// / / / /
notices = []
logs = []
unreadMessages = 0
unreadTransfers = 0
seenIncomingTransfers = []
activeTab = "transfer"
pendingLoginRequest = nil
//
RecordsStore.clear("history")
RecordsStore.clear("messages")
@@ -215,6 +269,53 @@ final class EngineController: NSObject
RecordsStore.clear("messages")
}
// ---- #2 ----
// notify 4s
//
func notify(_ text: String, kind: Notice.Kind)
{
guard !text.isEmpty else { return }
let notice = Notice(kind: kind, text: text)
notices.insert(notice, at: 0)
Task
{
try? await Task.sleep(for: .seconds(4))
dismissNotice(notice.id)
}
}
func dismissNotice(_ id: UUID)
{
notices.removeAll { $0.id == id }
}
// ---- #5 ----
// setActiveTab
func setActiveTab(_ tab: String)
{
activeTab = tab
if tab == "messages" { unreadMessages = 0 }
if tab == "transfer" { unreadTransfers = 0 }
}
// noteIncomingTransfer sessionId
//
private func noteIncomingTransfer(_ item: TransferItem)
{
guard item.direction == "incoming", !seenIncomingTransfers.contains(item.sessionId) else { return }
seenIncomingTransfers.insert(item.sessionId)
if activeTab != "transfer" { unreadTransfers += 1 }
}
// ---- #3 ----
func clearLogs()
{
logs.removeAll()
}
// Documents Files app /
func receivedFiles() -> [URL] { downloads.receivedFiles() }
func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) }
@@ -239,15 +340,13 @@ final class EngineController: NSObject
func uploadClipboard()
{
let content = UIPasteboard.general.string ?? ""
guard !content.isEmpty else { clipboardStatus = t("ios.clipboard.empty"); return }
clipboardStatus = t("ios.clipboard.uploading")
guard !content.isEmpty else { notify(t("ios.clipboard.empty"), kind: .info); return }
sendCommand("clipboardUpload", payload: [ "content": content ])
}
// UIPasteboard handleNotify "clipboard"
func pullClipboard()
{
clipboardStatus = t("ios.clipboard.pulling")
sendCommand("clipboardPull", payload: [:])
}
@@ -255,10 +354,38 @@ final class EngineController: NSObject
// #2
func revokeDevice(_ deviceID: String)
{
deviceActionStatus = ""
sendCommand("revokeDevice", payload: [ "device_id": deviceID ])
}
// ---- #8 ----
// /link?r=&c=qrRequest
// requestId / code
func fetchLoginRequest(requestId: String, code: String)
{
sendCommand("fetchLoginRequest", payload: [ "request_id": requestId, "code": code ])
}
// full访 Web/PWA
func approveLogin()
{
guard let req = pendingLoginRequest else { return }
pendingLoginRequest = nil
sendCommand("approveLogin", payload: [ "request_id": req.requestId, "code": req.code ])
}
func denyLogin()
{
guard let req = pendingLoginRequest else { return }
pendingLoginRequest = nil
sendCommand("denyLogin", payload: [ "request_id": req.requestId, "code": req.code ])
}
func cancelLoginRequest()
{
pendingLoginRequest = nil
}
// O CANCELLED transfers / transferDone
func cancelTransfer(_ sessionId: String)
{
@@ -507,6 +634,7 @@ extension EngineController: WKScriptMessageHandler
if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
{
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
for item in transfers { noteIncomingTransfer(item) } // #5
syncBackgroundTask()
}
case "message":
@@ -516,6 +644,7 @@ extension EngineController: WKScriptMessageHandler
let m = p["message"] as? [String: Any],
let item = Self.parseMessage(m)
{
let isNew = !messages.contains { $0.id == item.id }
messages.removeAll { $0.id == item.id }
messages.insert(item, at: 0)
if messages.count > Self.messagesCap
@@ -523,11 +652,14 @@ extension EngineController: WKScriptMessageHandler
messages.removeLast(messages.count - Self.messagesCap)
}
RecordsStore.save(messages, "messages")
// #5outgoing
if isNew, item.direction == "incoming", activeTab != "messages" { unreadMessages += 1 }
}
case "transferDone":
// sessionId
if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
{
noteIncomingTransfer(item) // #5 seen
transfers.removeAll { $0.sessionId == item.sessionId }
history.removeAll { $0.sessionId == item.sessionId }
history.insert(item, at: 0)
@@ -549,22 +681,42 @@ extension EngineController: WKScriptMessageHandler
if let p = payload as? [String: Any], let msg = p["msg"] as? String
{
lastEngineLog = msg
logs.insert(LogEntry(level: p["level"] as? String ?? "info", message: msg, at: Date()), at: 0)
if logs.count > Self.logsCap { logs.removeLast(logs.count - Self.logsCap) }
}
case "clipboard":
// UIPasteboard
if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
{
UIPasteboard.general.string = content
clipboardStatus = t("ios.clipboard.pulled")
notify(t("ios.clipboard.pulled"), kind: .success)
}
else
{
clipboardStatus = t("ios.clipboard.empty")
notify(t("ios.clipboard.empty"), kind: .info)
}
case "clipboardUploaded":
clipboardStatus = t("ios.clipboard.uploaded")
notify(t("ios.clipboard.uploaded"), kind: .success)
case "deviceRevoked":
deviceActionStatus = t("ios.devices.revoked")
notify(t("ios.devices.revoked"), kind: .success)
case "loginRequest":
// #8
if let p = payload as? [String: Any],
let rid = p["request_id"] as? String, let code = p["code"] as? String
{
pendingLoginRequest = LoginRequestItem(
requestId: rid, code: code,
deviceName: p["device_name"] as? String ?? "",
deviceType: p["device_type"] as? String ?? "",
requestIp: p["request_ip"] as? String ?? "")
}
case "loginApproved":
notify(t("ios.scan.approved"), kind: .success)
case "loginDenied":
notify(t("ios.scan.denied"), kind: .success)
case "loginFailed":
pendingLoginRequest = nil
notify(t("ios.scan.failed"), kind: .error)
case "identityUpdated":
// /api/me QR UUID + Keychain
// UUID#12
@@ -580,7 +732,7 @@ extension EngineController: WKScriptMessageHandler
DeviceNameStore.value = name
auth?.updateDeviceName(name)
deviceName = name
deviceActionStatus = t("ios.settings.deviceName.success")
notify(t("ios.settings.deviceName.success"), kind: .success)
}
case "error":
// error /
@@ -588,11 +740,11 @@ extension EngineController: WKScriptMessageHandler
{
let stage = p["stage"] as? String ?? ""
let msg = p["message"] as? String ?? ""
if stage == "clipboard" { clipboardStatus = t("ios.clipboard.failed") }
if stage == "clipboard" { notify(t("ios.clipboard.failed"), kind: .error) }
else if stage == "revoke"
{
deviceActionStatus = msg == "step_up_required"
? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed")
notify(msg == "step_up_required"
? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed"), kind: .error)
}
else { status = "错误:\(msg)" }
}