diff --git a/ios/CDrop/Shared/Theme.swift b/ios/CDrop/Shared/Theme.swift new file mode 100644 index 0000000..7569585 --- /dev/null +++ b/ios/CDrop/Shared/Theme.swift @@ -0,0 +1,14 @@ +import SwiftUI +import UIKit + +// 品牌主色单一真源(强约束③,与 web 主 accent 对齐):浅色 #644AC9 / 深色 #9580FF。放在 Shared,使主 +// app、控件 / 小组件扩展共用同一定义(此前仅在主 app 的 RootView 内,扩展无法引用)。 +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) + }) +} diff --git a/ios/CDrop/Sources/Engine/EngineController.swift b/ios/CDrop/Sources/Engine/EngineController.swift index 37496aa..a53ce63 100644 --- a/ios/CDrop/Sources/Engine/EngineController.swift +++ b/ios/CDrop/Sources/Engine/EngineController.swift @@ -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 = [] + // 引擎 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)" } } diff --git a/ios/CDrop/Sources/LogView.swift b/ios/CDrop/Sources/LogView.swift new file mode 100644 index 0000000..5e7ff23 --- /dev/null +++ b/ios/CDrop/Sources/LogView.swift @@ -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) + } +} diff --git a/ios/CDrop/Sources/NotificationBanner.swift b/ios/CDrop/Sources/NotificationBanner.swift new file mode 100644 index 0000000..ddd975b --- /dev/null +++ b/ios/CDrop/Sources/NotificationBanner.swift @@ -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 + } + } +} diff --git a/ios/CDrop/Sources/Resources/i18n/en-US.json b/ios/CDrop/Sources/Resources/i18n/en-US.json index 1a48f91..13e2c17 100644 --- a/ios/CDrop/Sources/Resources/i18n/en-US.json +++ b/ios/CDrop/Sources/Resources/i18n/en-US.json @@ -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" } diff --git a/ios/CDrop/Sources/Resources/i18n/zh-CN.json b/ios/CDrop/Sources/Resources/i18n/zh-CN.json index 80ab776..decba54 100644 --- a/ios/CDrop/Sources/Resources/i18n/zh-CN.json +++ b/ios/CDrop/Sources/Resources/i18n/zh-CN.json @@ -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 登录" } diff --git a/ios/CDrop/Sources/Resources/i18n/zh-TW.json b/ios/CDrop/Sources/Resources/i18n/zh-TW.json index c2f08be..2daa73f 100644 --- a/ios/CDrop/Sources/Resources/i18n/zh-TW.json +++ b/ios/CDrop/Sources/Resources/i18n/zh-TW.json @@ -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 登入" } diff --git a/ios/CDrop/Sources/RootView.swift b/ios/CDrop/Sources/RootView.swift index da7f028..cae7139 100644 --- a/ios/CDrop/Sources/RootView.swift +++ b/ios/CDrop/Sources/RootView.swift @@ -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 与控件 / 小组件扩展共用单一真源)。 diff --git a/ios/CDrop/Sources/ScannerView.swift b/ios/CDrop/Sources/ScannerView.swift new file mode 100644 index 0000000..8f16852 --- /dev/null +++ b/ios/CDrop/Sources/ScannerView.swift @@ -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 + } + } + } + } +} diff --git a/ios/CDrop/Widgets/ClipboardControls.swift b/ios/CDrop/Widgets/ClipboardControls.swift index 7b1c630..46c0a51 100644 --- a/ios/CDrop/Widgets/ClipboardControls.swift +++ b/ios/CDrop/Widgets/ClipboardControls.swift @@ -3,7 +3,8 @@ import SwiftUI import WidgetKit // 控制中心两控件(iOS 18+ ControlWidget,PLAN 决策 C / §I4):上传 / 拉取云剪贴板,零推送、 -// 用户主动触发。控件标签经 t() 跟随设备语言(i18n 单一真源,随扩展 bundle 一并打包)。 +// 用户主动触发。控件标签经 t() 跟随设备语言(i18n 单一真源,随扩展 bundle 一并打包)。另含主屏交互式 +// 小组件 CloudClipboardWidget(#6):同样的上传 / 拉取,可放主屏 / 锁屏。 @main struct CDropWidgetBundle: WidgetBundle { @@ -11,6 +12,7 @@ struct CDropWidgetBundle: WidgetBundle { UploadClipboardControl() PullClipboardControl() + CloudClipboardWidget() } } diff --git a/ios/CDrop/Widgets/ClipboardIntents.swift b/ios/CDrop/Widgets/ClipboardIntents.swift index b7ec736..2e0015d 100644 --- a/ios/CDrop/Widgets/ClipboardIntents.swift +++ b/ios/CDrop/Widgets/ClipboardIntents.swift @@ -1,7 +1,8 @@ import AppIntents import UIKit -// 「上传剪贴板」控件动作:读本机剪贴板文本,经控件专用会话推到云剪贴板。 +// 「上传剪贴板」动作:读本机剪贴板文本,经控件专用会话推到云剪贴板。控制中心控件与主屏小组件按钮 +// 共用此 intent。App 外触发时经 WidgetFeedback 弹本地通知反馈成功 / 失败(#6)。 struct UploadClipboardIntent: AppIntent { static var title: LocalizedStringResource = "上传剪贴板" @@ -10,12 +11,29 @@ struct UploadClipboardIntent: AppIntent func perform() async throws -> some IntentResult { let text = await MainActor.run { UIPasteboard.general.string ?? "" } - if !text.isEmpty { try? await ClipboardClient.upload(text) } + guard !text.isEmpty else + { + await WidgetFeedback.post(t("ios.clipboard.empty")) + return .result() + } + do + { + try await ClipboardClient.upload(text) + await WidgetFeedback.post(t("ios.clipboard.uploaded")) + } + catch ClipboardClient.ClientError.noSession + { + await WidgetFeedback.post(t("ios.widget.needApp")) + } + catch + { + await WidgetFeedback.post(t("ios.clipboard.failed")) + } return .result() } } -// 「拉取剪贴板」控件动作:拉云剪贴板内容写入本机剪贴板。 +// 「拉取剪贴板」动作:拉云剪贴板内容写入本机剪贴板。同上,App 外触发弹本地通知反馈。 struct PullClipboardIntent: AppIntent { static var title: LocalizedStringResource = "拉取剪贴板" @@ -23,9 +41,26 @@ struct PullClipboardIntent: AppIntent func perform() async throws -> some IntentResult { - if let text = try? await ClipboardClient.pull(), !text.isEmpty + do { - await MainActor.run { UIPasteboard.general.string = text } + let text = try await ClipboardClient.pull() + if text.isEmpty + { + await WidgetFeedback.post(t("ios.clipboard.empty")) + } + else + { + await MainActor.run { UIPasteboard.general.string = text } + await WidgetFeedback.post(t("ios.clipboard.pulled")) + } + } + catch ClipboardClient.ClientError.noSession + { + await WidgetFeedback.post(t("ios.widget.needApp")) + } + catch + { + await WidgetFeedback.post(t("ios.clipboard.failed")) } return .result() } diff --git a/ios/CDrop/Widgets/CloudClipboardWidget.swift b/ios/CDrop/Widgets/CloudClipboardWidget.swift new file mode 100644 index 0000000..1f5c32d --- /dev/null +++ b/ios/CDrop/Widgets/CloudClipboardWidget.swift @@ -0,0 +1,92 @@ +import AppIntents +import SwiftUI +import WidgetKit + +// 主屏 / 锁屏小组件「云剪贴板」(#6):在控制中心两控件之外,提供一个可放主屏的交互式小组件,含上传 / +// 拉取两个按钮(iOS 17+ 交互式小组件,按钮直接跑 AppIntent,无需打开 app)。触发结果经 WidgetFeedback +// 弹本地通知反馈。按钮复用与控制中心相同的 Upload/PullClipboardIntent。 +struct ClipboardWidgetEntry: TimelineEntry +{ + let date: Date +} + +// 纯按钮小组件,无动态内容:单条永不刷新的时间线即可(交互由按钮的 AppIntent 驱动)。 +struct ClipboardWidgetProvider: TimelineProvider +{ + func placeholder(in context: Context) -> ClipboardWidgetEntry { ClipboardWidgetEntry(date: Date()) } + + func getSnapshot(in context: Context, completion: @escaping (ClipboardWidgetEntry) -> Void) + { + completion(ClipboardWidgetEntry(date: Date())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) + { + completion(Timeline(entries: [ ClipboardWidgetEntry(date: Date()) ], policy: .never)) + } +} + +struct ClipboardWidgetView: View +{ + @Environment(\.widgetFamily) private var family + var entry: ClipboardWidgetEntry + + var body: some View + { + VStack(alignment: .leading, spacing: 10) + { + HStack(spacing: 6) + { + Image(systemName: "doc.on.clipboard") + .foregroundStyle(.tint) + Text(t("ios.widget.clipboardName")) + .font(.caption).bold() + .lineLimit(1) + } + // 小尺寸纵向堆叠两按钮;中尺寸并排。 + if family == .systemSmall + { + actionButton(t("ios.control.upload"), "square.and.arrow.up", UploadClipboardIntent()) + actionButton(t("ios.control.pull"), "square.and.arrow.down", PullClipboardIntent()) + } + else + { + HStack(spacing: 10) + { + actionButton(t("ios.control.upload"), "square.and.arrow.up", UploadClipboardIntent()) + actionButton(t("ios.control.pull"), "square.and.arrow.down", PullClipboardIntent()) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding() + .containerBackground(.fill.tertiary, for: .widget) + } + + private func actionButton(_ title: String, _ symbol: String, _ intent: some AppIntent) -> some View + { + Button(intent: intent) + { + Label(title, systemImage: symbol) + .font(.caption2) + .lineLimit(1) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.cdropAccent) + } +} + +struct CloudClipboardWidget: Widget +{ + var body: some WidgetConfiguration + { + StaticConfiguration(kind: "net.commilitia.cdrop.widget.clipboard", provider: ClipboardWidgetProvider()) + { entry in + ClipboardWidgetView(entry: entry) + } + .configurationDisplayName(t("ios.widget.clipboardName")) + .description(t("ios.widget.clipboardDesc")) + .supportedFamilies([ .systemSmall, .systemMedium ]) + } +} diff --git a/ios/CDrop/Widgets/WidgetFeedback.swift b/ios/CDrop/Widgets/WidgetFeedback.swift new file mode 100644 index 0000000..3d70310 --- /dev/null +++ b/ios/CDrop/Widgets/WidgetFeedback.swift @@ -0,0 +1,19 @@ +import Foundation +import UserNotifications + +// 控件 / 小组件在 App 外触发剪贴板操作时的成功 / 失败反馈(#6):弹一条本地通知。控件 / 小组件在独立扩展 +// 进程运行、不开主 app,没有应用内横幅可用,故用本地通知作反馈。主 app 启动时已申请通知授权,扩展共享 +// 同一授权;未授权则系统静默忽略(best-effort,不抛错)。 +enum WidgetFeedback +{ + static func post(_ body: String) async + { + guard !body.isEmpty else { return } + let content = UNMutableNotificationContent() + content.title = t("app.brand") + content.body = body + // trigger nil=立即送达。标识符随机,避免相邻多次反馈互相覆盖。 + let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + try? await UNUserNotificationCenter.current().add(req) + } +} diff --git a/web/src/engine/main.ts b/web/src/engine/main.ts index dd45fc2..287bc81 100644 --- a/web/src/engine/main.ts +++ b/web/src/engine/main.ts @@ -27,6 +27,7 @@ import { startHub } from "../features/transfer/hub"; import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers"; import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer"; import { bridgeFileSource, isIOSShell, notifyNative, onNativeEvent } from "../net/ios"; +import { qrApprove, qrDeny, qrRequest } from "../net/qr"; import { useAppStore } from "../store"; import type { TransferRecord } from "../store/types"; @@ -277,6 +278,26 @@ function bindCommands(): void if (!p.device_id || !p.name) { return; } void renameSelf(p.device_id, p.name); }); + // 扫码登录他端(#8):原生(已登录)扫到另一台设备的登录二维码后,送来 { request_id, code }。 + // 先拉请求信息回报原生供安全确认,再据原生指令批准 / 拒绝(qr.ts,走 apiFetch 带本机 Bearer)。 + onNativeEvent("fetchLoginRequest", (payload) => + { + const p = payload as { request_id?: string; code?: string }; + if (!p.request_id || !p.code) { return; } + void fetchLoginRequest(p.request_id, p.code); + }); + onNativeEvent("approveLogin", (payload) => + { + const p = payload as { request_id?: string; code?: string }; + if (!p.request_id || !p.code) { return; } + void approveLogin(p.request_id, p.code); + }); + onNativeEvent("denyLogin", (payload) => + { + const p = payload as { request_id?: string; code?: string }; + if (!p.request_id || !p.code) { return; } + void denyLogin(p.request_id, p.code); + }); // APNs 设备令牌登记:原生(AppDelegate)拿到令牌经桥送来,引擎用新鲜会话 token 上报后端。 onNativeEvent("registerPush", (payload) => { @@ -392,6 +413,55 @@ async function renameSelf(deviceID: string, name: string): Promise } } +// 扫码登录他端(#8):本机持完整会话,代另一台设备批准其登录请求。三步映射到 qr.ts: +// fetchLoginRequest → qrRequest 拉待批准设备信息,回报原生渲染确认(不盲批); +// approveLogin → qrApprove 授予完整会话(full + persist,原生只接受 full); +// denyLogin → qrDeny 拒绝。任一失败回报 loginFailed(码失效 / 已处理 / 网络),原生提示并清待批准态。 +async function fetchLoginRequest(requestId: string, code: string): Promise +{ + try + { + const info = await qrRequest(requestId, code); + notifyNative("loginRequest", { + request_id: requestId, + code, + device_name: info.deviceName, + device_type: info.deviceType, + request_ip: info.requestIp, + }); + } + catch + { + notifyNative("loginFailed", {}); + } +} + +async function approveLogin(requestId: string, code: string): Promise +{ + try + { + await qrApprove(requestId, code, "full", "persist"); + notifyNative("loginApproved", {}); + } + catch + { + notifyNative("loginFailed", {}); + } +} + +async function denyLogin(requestId: string, code: string): Promise +{ + try + { + await qrDeny(requestId, code); + notifyNative("loginDenied", {}); + } + catch + { + notifyNative("loginFailed", {}); + } +} + // healIOSIdentity corrects the display identity after a QR login. collectQRSession returns the // subject UUID as the name (cdrop holds no accounts table; qr.go), so the injected session shows // "你好, ". Once the token is live at the edge, /api/me carries the real X-Auth-Name: read diff --git a/web/src/features/auth/auth.ts b/web/src/features/auth/auth.ts index 626a276..3d5b257 100644 --- a/web/src/features/auth/auth.ts +++ b/web/src/features/auth/auth.ts @@ -129,8 +129,28 @@ async function brokerRefresh(refreshToken: string): Promise // (server-confirmed dead) lets the caller forceLogout, "transient" must keep state. export type RefreshOutcome = "refreshed" | "invalid" | "transient"; -// refreshTokens renews the access token; api.ts calls it lazily on a 401. -export async function refreshTokens(): Promise +// Single-flight guard. The broker rotates refresh tokens single-use: a refresh consumes +// the current refresh token and returns a fresh pair, invalidating the old one. On a cold +// boot after a long gap (iOS engine reopened next morning) the access token is expired, so +// the boot's concurrent authed calls (SSE + /api/me + ICE + scope) all 401 at once. Without +// this guard each 401 calls refreshTokens() in parallel with the SAME stored refresh token: +// the first rotates it, the rest replay the now-invalidated token → broker 401 → "invalid" +// → forceLogout → a spurious re-login every morning. Collapsing concurrent refreshes onto a +// single in-flight promise means one rotation, one new pair, and every waiter retries with +// the fresh access token. (Sequential refreshes are safe — each uses the latest token.) +let refreshInFlight: Promise | null = null; + +// refreshTokens renews the access token; api.ts calls it lazily on a 401. Concurrent callers +// share one in-flight refresh (see refreshInFlight) so a single-use rotating refresh token is +// consumed exactly once. +export function refreshTokens(): Promise +{ + if (refreshInFlight) { return refreshInFlight; } + refreshInFlight = doRefreshTokens().finally(() => { refreshInFlight = null; }); + return refreshInFlight; +} + +async function doRefreshTokens(): Promise { const store = useAppStore.getState(); if (store.authMode !== "prod") { return "transient"; } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index f72a20b..72a44c1 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -429,4 +429,24 @@ export const enUS: Partial = { "ios.control.upload": "Upload Clipboard", "ios.control.pull": "Pull Clipboard", "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", }; diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index ee4b625..08fa37c 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -425,4 +425,24 @@ export const zhCN = { "ios.control.upload": "上传剪贴板", "ios.control.pull": "拉取剪贴板", "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 登录", } as const; diff --git a/web/src/i18n/locales/zh-TW.ts b/web/src/i18n/locales/zh-TW.ts index 1fa4a99..d58ef6c 100644 --- a/web/src/i18n/locales/zh-TW.ts +++ b/web/src/i18n/locales/zh-TW.ts @@ -429,4 +429,24 @@ export const zhTW: Partial = { "ios.control.upload": "上傳剪貼簿", "ios.control.pull": "拉取剪貼簿", "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 登入", };