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
}
// 引擎日志条目(#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)" }
}
+94
View File
@@ -0,0 +1,94 @@
import SwiftUI
// 引擎日志独立界面(#3):展示 EngineController.logs 环形缓冲里最近 N 条 console.warn/error,分级配色、
// 可选中复制、可一键清空。从设置页「查看日志」进入。便于在真机上(无 Web Inspector)定位 SSE / 鉴权 /
// 传输失败原因——此前只能在设置页看到孤零零一行 lastEngineLog。
struct LogView: View
{
@Environment(EngineController.self) private var engine
var body: some View
{
Group
{
if engine.logs.isEmpty
{
ContentUnavailableView(t("ios.logs.empty"), systemImage: "doc.text")
}
else
{
List
{
ForEach(engine.logs)
{ entry in
logRow(entry)
}
}
.listStyle(.plain)
}
}
.navigationTitle(t("ios.logs.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar
{
if !engine.logs.isEmpty
{
ToolbarItem(placement: .topBarTrailing)
{
Button(role: .destructive) { engine.clearLogs() }
label: { Label(t("ios.logs.clear"), systemImage: "trash") }
}
}
}
}
private func logRow(_ entry: LogEntry) -> some View
{
VStack(alignment: .leading, spacing: 4)
{
HStack(spacing: 6)
{
Image(systemName: levelSymbol(entry.level))
.font(.caption)
.foregroundStyle(levelTint(entry.level))
Text(timeString(entry.at))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Text(entry.message)
.font(.caption.monospaced())
.foregroundStyle(.primary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 2)
}
private func levelSymbol(_ level: String) -> String
{
switch level
{
case "error": return "xmark.octagon.fill"
case "warn": return "exclamationmark.triangle.fill"
default: return "info.circle"
}
}
private func levelTint(_ level: String) -> Color
{
switch level
{
case "error": return .red
case "warn": return .orange
default: return .secondary
}
}
private func timeString(_ date: Date) -> String
{
let f = DateFormatter()
f.dateFormat = "HH:mm:ss"
return f.string(from: date)
}
}
@@ -0,0 +1,80 @@
import SwiftUI
// 瞬时通知横幅(#2):覆盖在标签内容顶部,醒目、状态分明(成功绿 / 失败红 / 信息中性)、自动消失
// (EngineController.notify 安排 4s 后移除)、可点叉手动清除。取代原先散落在设备页 / 设置页、串页显示
// 且永不清除的灰色小字(deviceActionStatus / clipboardStatus)。由 EngineController.notices 驱动。
struct NoticeOverlay: View
{
@Environment(EngineController.self) private var engine
var body: some View
{
VStack(spacing: 8)
{
ForEach(engine.notices)
{ notice in
NoticeBanner(notice: notice) { engine.dismissNotice(notice.id) }
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.animation(.spring(duration: 0.3), value: engine.notices)
// 无通知时不拦截下方内容的点击;有通知时仅横幅自身(含叉按钮)可点。
.allowsHitTesting(!engine.notices.isEmpty)
}
}
private struct NoticeBanner: View
{
let notice: Notice
let onDismiss: () -> Void
var body: some View
{
HStack(spacing: 10)
{
Image(systemName: symbol)
.font(.headline)
.foregroundStyle(tint)
Text(notice.text)
.font(.subheadline)
.foregroundStyle(.primary)
.lineLimit(2)
Spacer(minLength: 8)
Button { onDismiss() }
label:
{
Image(systemName: "xmark")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding(.vertical, 12)
.padding(.horizontal, 14)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(tint.opacity(0.35), lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 8, y: 2)
}
private var symbol: String
{
switch notice.kind
{
case .success: return "checkmark.circle.fill"
case .error: return "exclamationmark.triangle.fill"
case .info: return "info.circle.fill"
}
}
private var tint: Color
{
switch notice.kind
{
case .success: return .green
case .error: return .red
case .info: return .cdropAccent
}
}
}
+21 -1
View File
@@ -374,5 +374,25 @@
"ios.share.title": "Send {{n}} file(s) to a device",
"ios.control.upload": "Upload Clipboard",
"ios.control.pull": "Pull Clipboard",
"ios.control.sessionLabel": "{{name}} · Control"
"ios.control.sessionLabel": "{{name}} · Control",
"ios.settings.viewLogs": "View Logs",
"ios.logs.title": "Engine Logs",
"ios.logs.empty": "No logs yet",
"ios.logs.clear": "Clear Logs",
"ios.logs.copy": "Copy All",
"ios.scan.entry": "Scan to Sign In",
"ios.scan.title": "Scan QR Code",
"ios.scan.hint": "Point at the sign-in QR code on another device",
"ios.scan.invalid": "Unrecognized sign-in QR code",
"ios.scan.approveTitle": "Approve this device to sign in?",
"ios.scan.approve": "Approve",
"ios.scan.deny": "Deny",
"ios.scan.approved": "Device approved",
"ios.scan.denied": "Device denied",
"ios.scan.failed": "Action failed, please retry",
"ios.scan.cameraDenied": "Camera access is required to scan; enable it in Settings",
"ios.scan.requestIp": "Source IP",
"ios.widget.clipboardName": "Cloud Clipboard",
"ios.widget.clipboardDesc": "Quickly upload or pull the shared clipboard",
"ios.widget.needApp": "Open the app to sign in first"
}
+21 -1
View File
@@ -374,5 +374,25 @@
"ios.share.title": "发送 {{n}} 个文件到设备",
"ios.control.upload": "上传剪贴板",
"ios.control.pull": "拉取剪贴板",
"ios.control.sessionLabel": "{{name}} · 控件"
"ios.control.sessionLabel": "{{name}} · 控件",
"ios.settings.viewLogs": "查看日志",
"ios.logs.title": "引擎日志",
"ios.logs.empty": "暂无日志",
"ios.logs.clear": "清空日志",
"ios.logs.copy": "复制全部",
"ios.scan.entry": "扫码登录设备",
"ios.scan.title": "扫码登录",
"ios.scan.hint": "对准另一台设备上的登录二维码",
"ios.scan.invalid": "无法识别的登录二维码",
"ios.scan.approveTitle": "批准此设备登录本账号?",
"ios.scan.approve": "批准登录",
"ios.scan.deny": "拒绝",
"ios.scan.approved": "已批准设备登录",
"ios.scan.denied": "已拒绝设备登录",
"ios.scan.failed": "操作失败,请重试",
"ios.scan.cameraDenied": "需要相机权限才能扫码,请在系统设置中开启",
"ios.scan.requestIp": "来源 IP",
"ios.widget.clipboardName": "云剪贴板",
"ios.widget.clipboardDesc": "快速上传或拉取设备间剪贴板",
"ios.widget.needApp": "请先打开 App 登录"
}
+21 -1
View File
@@ -374,5 +374,25 @@
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
"ios.control.upload": "上傳剪貼簿",
"ios.control.pull": "拉取剪貼簿",
"ios.control.sessionLabel": "{{name}} · 控制項"
"ios.control.sessionLabel": "{{name}} · 控制項",
"ios.settings.viewLogs": "檢視日誌",
"ios.logs.title": "引擎日誌",
"ios.logs.empty": "尚無日誌",
"ios.logs.clear": "清空日誌",
"ios.logs.copy": "複製全部",
"ios.scan.entry": "掃碼登入裝置",
"ios.scan.title": "掃碼登入",
"ios.scan.hint": "對準另一台裝置上的登入 QR 碼",
"ios.scan.invalid": "無法辨識的登入 QR 碼",
"ios.scan.approveTitle": "批准此裝置登入本帳號?",
"ios.scan.approve": "批准登入",
"ios.scan.deny": "拒絕",
"ios.scan.approved": "已批准裝置登入",
"ios.scan.denied": "已拒絕裝置登入",
"ios.scan.failed": "操作失敗,請重試",
"ios.scan.cameraDenied": "需要相機權限才能掃碼,請在系統設定中開啟",
"ios.scan.requestIp": "來源 IP",
"ios.widget.clipboardName": "雲剪貼簿",
"ios.widget.clipboardDesc": "快速上傳或拉取裝置間剪貼簿",
"ios.widget.needApp": "請先開啟 App 登入"
}
+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:) 程序化推栈(按 sessionId,String 即 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 与控件 / 小组件扩展共用单一真源)。
+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
// nil=权限询问中;true/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
}
}
}
}
}