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:
@@ -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
|
||||
}
|
||||
|
||||
// 引擎日志条目(#3):installLogBridge 过桥来的 console.warn/error 逐条入环形缓冲,供独立日志界面
|
||||
// 查看最近 N 条(而非仅设置页一行 lastEngineLog)。at 为本机收到时刻,用于时间戳显示。
|
||||
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.ts、ios/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 = ""
|
||||
|
||||
// 引擎日志环形缓冲(#3):log 事件逐条前插,限长 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://localhost——localhost
|
||||
// 是潜在可信源、算安全上下文,故 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")
|
||||
// 新到的对端消息:不在消息页时计未读红点(#5)。本机发出的乐观回显(outgoing)不计。
|
||||
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)" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user