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:
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -50,6 +50,38 @@ struct TransferItem: Identifiable, Equatable, Codable
|
|||||||
var id: String { sessionId }
|
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)。
|
// 原生 ↔ 无头 JS 引擎的桥(对侧契约见 web/src/net/ios.ts、ios/PLAN.md §2)。
|
||||||
// 注册名为 "cdropEngine" 的 WKScriptMessageHandler 收 { id, method, payload }(RPC)或
|
// 注册名为 "cdropEngine" 的 WKScriptMessageHandler 收 { id, method, payload }(RPC)或
|
||||||
// { notify, payload }(单向通知);RPC 处理完经 evaluateJavaScript 调
|
// { notify, payload }(单向通知);RPC 处理完经 evaluateJavaScript 调
|
||||||
@@ -75,14 +107,28 @@ final class EngineController: NSObject
|
|||||||
var presenceCount = 0
|
var presenceCount = 0
|
||||||
var lastEngineLog = ""
|
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 令牌登记:令牌可能先于引擎到达,
|
// 引擎 JS 是否已就绪(收到 "ready" 通知)。门控 APNs 令牌登记:令牌可能先于引擎到达,
|
||||||
// 故 didRegister 与 ready 双触发 flushPushRegistration,任一顺序都登记得上。
|
// 故 didRegister 与 ready 双触发 flushPushRegistration,任一顺序都登记得上。
|
||||||
private var engineReady = false
|
private var engineReady = false
|
||||||
|
|
||||||
// 剪贴板 / 设备管理操作的瞬时反馈(设置页 / 设备页展示)。
|
|
||||||
var clipboardStatus = ""
|
|
||||||
var deviceActionStatus = ""
|
|
||||||
|
|
||||||
// 引擎页地址:须安全源(WebRTC 在非安全 origin 可能被禁,见 PLAN R-iOS-3)。默认线上
|
// 引擎页地址:须安全源(WebRTC 在非安全 origin 可能被禁,见 PLAN R-iOS-3)。默认线上
|
||||||
// https;可经环境变量 CDROP_ENGINE_URL 覆盖(本机测试指向 http://localhost——localhost
|
// https;可经环境变量 CDROP_ENGINE_URL 覆盖(本机测试指向 http://localhost——localhost
|
||||||
// 是潜在可信源、算安全上下文,故 WebRTC 仍可用,同 Windows 桌面 loopback 思路)。
|
// 是潜在可信源、算安全上下文,故 WebRTC 仍可用,同 Windows 桌面 loopback 思路)。
|
||||||
@@ -175,6 +221,14 @@ final class EngineController: NSObject
|
|||||||
transfers = []
|
transfers = []
|
||||||
history = []
|
history = []
|
||||||
messages = []
|
messages = []
|
||||||
|
// 登出清空瞬时态:通知 / 日志 / 未读 / 已读传输集 / 待批准登录,不跨账号残留。
|
||||||
|
notices = []
|
||||||
|
logs = []
|
||||||
|
unreadMessages = 0
|
||||||
|
unreadTransfers = 0
|
||||||
|
seenIncomingTransfers = []
|
||||||
|
activeTab = "transfer"
|
||||||
|
pendingLoginRequest = nil
|
||||||
// 登出清空持久记录,不跨账号残留(重启保活仅针对同一登录态)。
|
// 登出清空持久记录,不跨账号残留(重启保活仅针对同一登录态)。
|
||||||
RecordsStore.clear("history")
|
RecordsStore.clear("history")
|
||||||
RecordsStore.clear("messages")
|
RecordsStore.clear("messages")
|
||||||
@@ -215,6 +269,53 @@ final class EngineController: NSObject
|
|||||||
RecordsStore.clear("messages")
|
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 同一批)。供「收到的文件」视图浏览 / 转发。
|
// 收到的文件(Documents 沙盒,与 Files app 同一批)。供「收到的文件」视图浏览 / 转发。
|
||||||
func receivedFiles() -> [URL] { downloads.receivedFiles() }
|
func receivedFiles() -> [URL] { downloads.receivedFiles() }
|
||||||
func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) }
|
func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) }
|
||||||
@@ -239,15 +340,13 @@ final class EngineController: NSObject
|
|||||||
func uploadClipboard()
|
func uploadClipboard()
|
||||||
{
|
{
|
||||||
let content = UIPasteboard.general.string ?? ""
|
let content = UIPasteboard.general.string ?? ""
|
||||||
guard !content.isEmpty else { clipboardStatus = t("ios.clipboard.empty"); return }
|
guard !content.isEmpty else { notify(t("ios.clipboard.empty"), kind: .info); return }
|
||||||
clipboardStatus = t("ios.clipboard.uploading")
|
|
||||||
sendCommand("clipboardUpload", payload: [ "content": content ])
|
sendCommand("clipboardUpload", payload: [ "content": content ])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 剪贴板下行:让引擎拉一次云剪贴板,回来写入 UIPasteboard(见 handleNotify "clipboard")。
|
// 剪贴板下行:让引擎拉一次云剪贴板,回来写入 UIPasteboard(见 handleNotify "clipboard")。
|
||||||
func pullClipboard()
|
func pullClipboard()
|
||||||
{
|
{
|
||||||
clipboardStatus = t("ios.clipboard.pulling")
|
|
||||||
sendCommand("clipboardPull", payload: [:])
|
sendCommand("clipboardPull", payload: [:])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,10 +354,38 @@ final class EngineController: NSObject
|
|||||||
// 之前传设备名致后端找不到而「移除失败」(#2)。
|
// 之前传设备名致后端找不到而「移除失败」(#2)。
|
||||||
func revokeDevice(_ deviceID: String)
|
func revokeDevice(_ deviceID: String)
|
||||||
{
|
{
|
||||||
deviceActionStatus = ""
|
|
||||||
sendCommand("revokeDevice", payload: [ "device_id": deviceID ])
|
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 回推。
|
// 主动取消一个进行中的传输(O)。引擎置该会话为 CANCELLED,随后 transfers / transferDone 回推。
|
||||||
func cancelTransfer(_ sessionId: String)
|
func cancelTransfer(_ sessionId: String)
|
||||||
{
|
{
|
||||||
@@ -507,6 +634,7 @@ extension EngineController: WKScriptMessageHandler
|
|||||||
if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
|
if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
|
||||||
{
|
{
|
||||||
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
|
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
|
||||||
|
for item in transfers { noteIncomingTransfer(item) } // 传入传输首现计未读红点(#5)
|
||||||
syncBackgroundTask()
|
syncBackgroundTask()
|
||||||
}
|
}
|
||||||
case "message":
|
case "message":
|
||||||
@@ -516,6 +644,7 @@ extension EngineController: WKScriptMessageHandler
|
|||||||
let m = p["message"] as? [String: Any],
|
let m = p["message"] as? [String: Any],
|
||||||
let item = Self.parseMessage(m)
|
let item = Self.parseMessage(m)
|
||||||
{
|
{
|
||||||
|
let isNew = !messages.contains { $0.id == item.id }
|
||||||
messages.removeAll { $0.id == item.id }
|
messages.removeAll { $0.id == item.id }
|
||||||
messages.insert(item, at: 0)
|
messages.insert(item, at: 0)
|
||||||
if messages.count > Self.messagesCap
|
if messages.count > Self.messagesCap
|
||||||
@@ -523,11 +652,14 @@ extension EngineController: WKScriptMessageHandler
|
|||||||
messages.removeLast(messages.count - Self.messagesCap)
|
messages.removeLast(messages.count - Self.messagesCap)
|
||||||
}
|
}
|
||||||
RecordsStore.save(messages, "messages")
|
RecordsStore.save(messages, "messages")
|
||||||
|
// 新到的对端消息:不在消息页时计未读红点(#5)。本机发出的乐观回显(outgoing)不计。
|
||||||
|
if isNew, item.direction == "incoming", activeTab != "messages" { unreadMessages += 1 }
|
||||||
}
|
}
|
||||||
case "transferDone":
|
case "transferDone":
|
||||||
// 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。
|
// 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。
|
||||||
if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
|
if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
|
||||||
{
|
{
|
||||||
|
noteIncomingTransfer(item) // 极快传输可能跳过活跃阶段,这里补计未读(#5,已 seen 则不重复)
|
||||||
transfers.removeAll { $0.sessionId == item.sessionId }
|
transfers.removeAll { $0.sessionId == item.sessionId }
|
||||||
history.removeAll { $0.sessionId == item.sessionId }
|
history.removeAll { $0.sessionId == item.sessionId }
|
||||||
history.insert(item, at: 0)
|
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
|
if let p = payload as? [String: Any], let msg = p["msg"] as? String
|
||||||
{
|
{
|
||||||
lastEngineLog = msg
|
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":
|
case "clipboard":
|
||||||
// 引擎拉回的云剪贴板内容 → 写入本机 UIPasteboard。
|
// 引擎拉回的云剪贴板内容 → 写入本机 UIPasteboard。
|
||||||
if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
|
if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
|
||||||
{
|
{
|
||||||
UIPasteboard.general.string = content
|
UIPasteboard.general.string = content
|
||||||
clipboardStatus = t("ios.clipboard.pulled")
|
notify(t("ios.clipboard.pulled"), kind: .success)
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
clipboardStatus = t("ios.clipboard.empty")
|
notify(t("ios.clipboard.empty"), kind: .info)
|
||||||
}
|
}
|
||||||
case "clipboardUploaded":
|
case "clipboardUploaded":
|
||||||
clipboardStatus = t("ios.clipboard.uploaded")
|
notify(t("ios.clipboard.uploaded"), kind: .success)
|
||||||
case "deviceRevoked":
|
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":
|
case "identityUpdated":
|
||||||
// 引擎据 /api/me 取到真实显示名(QR 登录注入的是 UUID 名)→ 更新会话身份 + Keychain,
|
// 引擎据 /api/me 取到真实显示名(QR 登录注入的是 UUID 名)→ 更新会话身份 + Keychain,
|
||||||
// 设置页据此显示真名而非 UUID(#12)。
|
// 设置页据此显示真名而非 UUID(#12)。
|
||||||
@@ -580,7 +732,7 @@ extension EngineController: WKScriptMessageHandler
|
|||||||
DeviceNameStore.value = name
|
DeviceNameStore.value = name
|
||||||
auth?.updateDeviceName(name)
|
auth?.updateDeviceName(name)
|
||||||
deviceName = name
|
deviceName = name
|
||||||
deviceActionStatus = t("ios.settings.deviceName.success")
|
notify(t("ios.settings.deviceName.success"), kind: .success)
|
||||||
}
|
}
|
||||||
case "error":
|
case "error":
|
||||||
// 复用现有 error 通知:剪贴板 / 吊销失败也走这里,分流到对应状态行。
|
// 复用现有 error 通知:剪贴板 / 吊销失败也走这里,分流到对应状态行。
|
||||||
@@ -588,11 +740,11 @@ extension EngineController: WKScriptMessageHandler
|
|||||||
{
|
{
|
||||||
let stage = p["stage"] as? String ?? ""
|
let stage = p["stage"] as? String ?? ""
|
||||||
let msg = p["message"] 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"
|
else if stage == "revoke"
|
||||||
{
|
{
|
||||||
deviceActionStatus = msg == "step_up_required"
|
notify(msg == "step_up_required"
|
||||||
? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed")
|
? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed"), kind: .error)
|
||||||
}
|
}
|
||||||
else { status = "错误:\(msg)" }
|
else { status = "错误:\(msg)" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -374,5 +374,25 @@
|
|||||||
"ios.share.title": "Send {{n}} file(s) to a device",
|
"ios.share.title": "Send {{n}} file(s) to a device",
|
||||||
"ios.control.upload": "Upload Clipboard",
|
"ios.control.upload": "Upload Clipboard",
|
||||||
"ios.control.pull": "Pull 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,5 +374,25 @@
|
|||||||
"ios.share.title": "发送 {{n}} 个文件到设备",
|
"ios.share.title": "发送 {{n}} 个文件到设备",
|
||||||
"ios.control.upload": "上传剪贴板",
|
"ios.control.upload": "上传剪贴板",
|
||||||
"ios.control.pull": "拉取剪贴板",
|
"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 登录"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,5 +374,25 @@
|
|||||||
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
|
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
|
||||||
"ios.control.upload": "上傳剪貼簿",
|
"ios.control.upload": "上傳剪貼簿",
|
||||||
"ios.control.pull": "拉取剪貼簿",
|
"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 登入"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ struct RootView: View
|
|||||||
{
|
{
|
||||||
TabView(selection: $selection)
|
TabView(selection: $selection)
|
||||||
{
|
{
|
||||||
|
// 顺序按领域与频次分组(#1):文件域(传输 → 收到的文件)相邻,其次消息,最后管理区
|
||||||
|
// (设备 → 设置)。传输 / 消息带未读红点(#5),对应页激活即清零。
|
||||||
Tab(t("ios.tab.transfer"), systemImage: "arrow.up.arrow.down", value: "transfer")
|
Tab(t("ios.tab.transfer"), systemImage: "arrow.up.arrow.down", value: "transfer")
|
||||||
{
|
{
|
||||||
NavigationStack
|
NavigationStack
|
||||||
@@ -27,11 +29,12 @@ struct RootView: View
|
|||||||
TransferListView()
|
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
|
NavigationStack
|
||||||
{
|
{
|
||||||
DeviceListView()
|
ReceivedFilesView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Tab(t("ios.tab.messages"), systemImage: "message", value: "messages")
|
Tab(t("ios.tab.messages"), systemImage: "message", value: "messages")
|
||||||
@@ -41,11 +44,12 @@ struct RootView: View
|
|||||||
MessagesView()
|
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
|
NavigationStack
|
||||||
{
|
{
|
||||||
ReceivedFilesView()
|
DeviceListView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Tab(t("ios.tab.settings"), systemImage: "gearshape", value: "settings")
|
Tab(t("ios.tab.settings"), systemImage: "gearshape", value: "settings")
|
||||||
@@ -58,6 +62,13 @@ struct RootView: View
|
|||||||
}
|
}
|
||||||
.tabBarMinimizeBehavior(.onScrollDown)
|
.tabBarMinimizeBehavior(.onScrollDown)
|
||||||
.tint(.cdropAccent)
|
.tint(.cdropAccent)
|
||||||
|
// 瞬时通知横幅覆盖在内容顶部(#2)。
|
||||||
|
.overlay(alignment: .top)
|
||||||
|
{
|
||||||
|
NoticeOverlay()
|
||||||
|
}
|
||||||
|
// 标签切换时同步当前页给引擎,驱动未读清零(#5)。
|
||||||
|
.onChange(of: selection) { engine.setActiveTab(selection) }
|
||||||
.background
|
.background
|
||||||
{
|
{
|
||||||
EngineWebView(controller: engine, auth: auth)
|
EngineWebView(controller: engine, auth: auth)
|
||||||
@@ -95,6 +106,9 @@ struct TransferListView: View
|
|||||||
@State private var showDevicePicker = false
|
@State private var showDevicePicker = false
|
||||||
@State private var showNoDevices = false
|
@State private var showNoDevices = false
|
||||||
@State private var showClearConfirm = false
|
@State private var showClearConfirm = false
|
||||||
|
// 点按传输卡片进详情:不在 List 内用 NavigationLink(会出现 chevron 改变卡片观感),改经
|
||||||
|
// navigationDestination(item:) 程序化推栈(按 sessionId,String 即 Hashable 标识),卡片保持原样。
|
||||||
|
@State private var detailSession: String?
|
||||||
|
|
||||||
// 可发送目标:在线、且非本机(共用 EngineController.sendableDevices)。
|
// 可发送目标:在线、且非本机(共用 EngineController.sendableDevices)。
|
||||||
private var sendableDevices: [DeviceItem]
|
private var sendableDevices: [DeviceItem]
|
||||||
@@ -104,44 +118,19 @@ struct TransferListView: View
|
|||||||
|
|
||||||
var body: some View
|
var body: some View
|
||||||
{
|
{
|
||||||
ScrollView
|
Group
|
||||||
{
|
{
|
||||||
if engine.transfers.isEmpty && engine.history.isEmpty
|
if engine.transfers.isEmpty && engine.history.isEmpty
|
||||||
{
|
{
|
||||||
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
|
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
|
||||||
.padding(.top, 80)
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
VStack(spacing: 16)
|
transferList
|
||||||
{
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(t("ios.tab.transfer"))
|
.navigationTitle(t("ios.tab.transfer"))
|
||||||
|
.navigationDestination(item: $detailSession) { sid in TransferDetailView(sessionId: sid) }
|
||||||
.toolbar
|
.toolbar
|
||||||
{
|
{
|
||||||
if !engine.history.isEmpty
|
if !engine.history.isEmpty
|
||||||
@@ -201,6 +190,32 @@ struct TransferListView: View
|
|||||||
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
|
.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
|
private func sectionHeader(_ title: String) -> some View
|
||||||
{
|
{
|
||||||
HStack
|
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)
|
private func startSend(to device: String)
|
||||||
@@ -424,6 +458,7 @@ struct DeviceListView: View
|
|||||||
@Environment(EngineController.self) private var engine
|
@Environment(EngineController.self) private var engine
|
||||||
@State private var revokeTarget: DeviceItem?
|
@State private var revokeTarget: DeviceItem?
|
||||||
@State private var showRevoke = false
|
@State private var showRevoke = false
|
||||||
|
@State private var showScanner = false
|
||||||
|
|
||||||
// 全部设备:含本机(本机标「本机」、不可移除自己——登出在设置页)。本机排最前。
|
// 全部设备:含本机(本机标「本机」、不可移除自己——登出在设置页)。本机排最前。
|
||||||
private var allDevices: [DeviceItem]
|
private var allDevices: [DeviceItem]
|
||||||
@@ -451,18 +486,24 @@ struct DeviceListView: View
|
|||||||
{
|
{
|
||||||
List
|
List
|
||||||
{
|
{
|
||||||
if !engine.deviceActionStatus.isEmpty
|
|
||||||
{
|
|
||||||
Text(engine.deviceActionStatus)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
ForEach(allDevices)
|
ForEach(allDevices)
|
||||||
{ dev in
|
{ dev in
|
||||||
deviceRow(dev)
|
deviceRow(dev)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { if !isSelf(dev) { revokeTarget = dev; showRevoke = true } }
|
// 长条行统一操作逻辑:滑动 + 长按都能移除(点按不再删除——破坏性操作不应误触)。
|
||||||
.swipeActions(edge: .trailing)
|
.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)
|
if !isSelf(dev)
|
||||||
{
|
{
|
||||||
@@ -475,6 +516,19 @@ struct DeviceListView: View
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(t("ios.tab.devices"))
|
.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)
|
.confirmationDialog(revokeTarget?.name ?? "", isPresented: $showRevoke, titleVisibility: .visible)
|
||||||
{
|
{
|
||||||
Button(t("ios.devices.remove"), role: .destructive)
|
Button(t("ios.devices.remove"), role: .destructive)
|
||||||
@@ -483,6 +537,40 @@ struct DeviceListView: View
|
|||||||
}
|
}
|
||||||
Button(t("common.cancel"), role: .cancel) { }
|
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
|
private func deviceRow(_ dev: DeviceItem) -> some View
|
||||||
@@ -537,12 +625,6 @@ struct SettingsView: View
|
|||||||
TextField(t("ios.settings.deviceName"), text: $deviceNameDraft)
|
TextField(t("ios.settings.deviceName"), text: $deviceNameDraft)
|
||||||
.submitLabel(.done)
|
.submitLabel(.done)
|
||||||
.onSubmit { commitRename() }
|
.onSubmit { commitRename() }
|
||||||
if !engine.deviceActionStatus.isEmpty
|
|
||||||
{
|
|
||||||
Text(engine.deviceActionStatus)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Button(role: .destructive) { logout() }
|
Button(role: .destructive) { logout() }
|
||||||
label:
|
label:
|
||||||
{
|
{
|
||||||
@@ -566,17 +648,23 @@ struct SettingsView: View
|
|||||||
LabeledContent(t("ios.settings.signaling"), value: signalingState)
|
LabeledContent(t("ios.settings.signaling"), value: signalingState)
|
||||||
LabeledContent(t("ios.settings.presenceEvents"), value: "\(engine.presenceCount)")
|
LabeledContent(t("ios.settings.presenceEvents"), value: "\(engine.presenceCount)")
|
||||||
LabeledContent(t("ios.settings.deviceCount"), value: "\(engine.devices.count)")
|
LabeledContent(t("ios.settings.deviceCount"), value: "\(engine.devices.count)")
|
||||||
if !engine.lastEngineLog.isEmpty
|
// 日志独立界面(#3):进入查看最近 N 条引擎日志;副标题预览最新一条。
|
||||||
|
NavigationLink
|
||||||
|
{
|
||||||
|
LogView()
|
||||||
|
}
|
||||||
|
label:
|
||||||
{
|
{
|
||||||
VStack(alignment: .leading, spacing: 2)
|
VStack(alignment: .leading, spacing: 2)
|
||||||
{
|
{
|
||||||
Text(t("ios.settings.lastLog"))
|
Text(t("ios.settings.viewLogs"))
|
||||||
.font(.caption)
|
if !engine.lastEngineLog.isEmpty
|
||||||
.foregroundStyle(.secondary)
|
{
|
||||||
Text(engine.lastEngineLog)
|
Text(engine.lastEngineLog)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.textSelection(.enabled)
|
.lineLimit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -586,12 +674,6 @@ struct SettingsView: View
|
|||||||
label: { Label(t("ios.clipboard.upload"), systemImage: "arrow.up.doc.on.clipboard") }
|
label: { Label(t("ios.clipboard.upload"), systemImage: "arrow.up.doc.on.clipboard") }
|
||||||
Button { engine.pullClipboard() }
|
Button { engine.pullClipboard() }
|
||||||
label: { Label(t("ios.clipboard.pull"), systemImage: "arrow.down.doc") }
|
label: { Label(t("ios.clipboard.pull"), systemImage: "arrow.down.doc") }
|
||||||
if !engine.clipboardStatus.isEmpty
|
|
||||||
{
|
|
||||||
Text(engine.clipboardStatus)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
header:
|
header:
|
||||||
{
|
{
|
||||||
@@ -800,14 +882,4 @@ private func deviceSymbol(_ type: String) -> String
|
|||||||
return "desktopcomputer"
|
return "desktopcomputer"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 品牌主色(强约束③,与 web 主 accent 对齐):浅色 #644AC9 / 深色 #9580FF。后续接 Asset
|
// 品牌主色 Color.cdropAccent 已移至 Shared/Theme.swift(主 app 与控件 / 小组件扩展共用单一真源)。
|
||||||
// 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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import SwiftUI
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
// 控制中心两控件(iOS 18+ ControlWidget,PLAN 决策 C / §I4):上传 / 拉取云剪贴板,零推送、
|
// 控制中心两控件(iOS 18+ ControlWidget,PLAN 决策 C / §I4):上传 / 拉取云剪贴板,零推送、
|
||||||
// 用户主动触发。控件标签经 t() 跟随设备语言(i18n 单一真源,随扩展 bundle 一并打包)。
|
// 用户主动触发。控件标签经 t() 跟随设备语言(i18n 单一真源,随扩展 bundle 一并打包)。另含主屏交互式
|
||||||
|
// 小组件 CloudClipboardWidget(#6):同样的上传 / 拉取,可放主屏 / 锁屏。
|
||||||
@main
|
@main
|
||||||
struct CDropWidgetBundle: WidgetBundle
|
struct CDropWidgetBundle: WidgetBundle
|
||||||
{
|
{
|
||||||
@@ -11,6 +12,7 @@ struct CDropWidgetBundle: WidgetBundle
|
|||||||
{
|
{
|
||||||
UploadClipboardControl()
|
UploadClipboardControl()
|
||||||
PullClipboardControl()
|
PullClipboardControl()
|
||||||
|
CloudClipboardWidget()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import AppIntents
|
import AppIntents
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
// 「上传剪贴板」控件动作:读本机剪贴板文本,经控件专用会话推到云剪贴板。
|
// 「上传剪贴板」动作:读本机剪贴板文本,经控件专用会话推到云剪贴板。控制中心控件与主屏小组件按钮
|
||||||
|
// 共用此 intent。App 外触发时经 WidgetFeedback 弹本地通知反馈成功 / 失败(#6)。
|
||||||
struct UploadClipboardIntent: AppIntent
|
struct UploadClipboardIntent: AppIntent
|
||||||
{
|
{
|
||||||
static var title: LocalizedStringResource = "上传剪贴板"
|
static var title: LocalizedStringResource = "上传剪贴板"
|
||||||
@@ -10,12 +11,29 @@ struct UploadClipboardIntent: AppIntent
|
|||||||
func perform() async throws -> some IntentResult
|
func perform() async throws -> some IntentResult
|
||||||
{
|
{
|
||||||
let text = await MainActor.run { UIPasteboard.general.string ?? "" }
|
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()
|
return .result()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 「拉取剪贴板」控件动作:拉云剪贴板内容写入本机剪贴板。
|
// 「拉取剪贴板」动作:拉云剪贴板内容写入本机剪贴板。同上,App 外触发弹本地通知反馈。
|
||||||
struct PullClipboardIntent: AppIntent
|
struct PullClipboardIntent: AppIntent
|
||||||
{
|
{
|
||||||
static var title: LocalizedStringResource = "拉取剪贴板"
|
static var title: LocalizedStringResource = "拉取剪贴板"
|
||||||
@@ -23,9 +41,26 @@ struct PullClipboardIntent: AppIntent
|
|||||||
|
|
||||||
func perform() async throws -> some IntentResult
|
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()
|
return .result()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ClipboardWidgetEntry>) -> 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 ])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { startHub } from "../features/transfer/hub";
|
|||||||
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
|
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
|
||||||
import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer";
|
import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer";
|
||||||
import { bridgeFileSource, isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
|
import { bridgeFileSource, isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
|
||||||
|
import { qrApprove, qrDeny, qrRequest } from "../net/qr";
|
||||||
import { useAppStore } from "../store";
|
import { useAppStore } from "../store";
|
||||||
import type { TransferRecord } from "../store/types";
|
import type { TransferRecord } from "../store/types";
|
||||||
|
|
||||||
@@ -277,6 +278,26 @@ function bindCommands(): void
|
|||||||
if (!p.device_id || !p.name) { return; }
|
if (!p.device_id || !p.name) { return; }
|
||||||
void renameSelf(p.device_id, p.name);
|
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 上报后端。
|
// APNs 设备令牌登记:原生(AppDelegate)拿到令牌经桥送来,引擎用新鲜会话 token 上报后端。
|
||||||
onNativeEvent("registerPush", (payload) =>
|
onNativeEvent("registerPush", (payload) =>
|
||||||
{
|
{
|
||||||
@@ -392,6 +413,55 @@ async function renameSelf(deviceID: string, name: string): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 扫码登录他端(#8):本机持完整会话,代另一台设备批准其登录请求。三步映射到 qr.ts:
|
||||||
|
// fetchLoginRequest → qrRequest 拉待批准设备信息,回报原生渲染确认(不盲批);
|
||||||
|
// approveLogin → qrApprove 授予完整会话(full + persist,原生只接受 full);
|
||||||
|
// denyLogin → qrDeny 拒绝。任一失败回报 loginFailed(码失效 / 已处理 / 网络),原生提示并清待批准态。
|
||||||
|
async function fetchLoginRequest(requestId: string, code: string): Promise<void>
|
||||||
|
{
|
||||||
|
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<void>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await qrApprove(requestId, code, "full", "persist");
|
||||||
|
notifyNative("loginApproved", {});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
notifyNative("loginFailed", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function denyLogin(requestId: string, code: string): Promise<void>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await qrDeny(requestId, code);
|
||||||
|
notifyNative("loginDenied", {});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
notifyNative("loginFailed", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// healIOSIdentity corrects the display identity after a QR login. collectQRSession returns the
|
// 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
|
// subject UUID as the name (cdrop holds no accounts table; qr.go), so the injected session shows
|
||||||
// "你好, <uuid>". Once the token is live at the edge, /api/me carries the real X-Auth-Name: read
|
// "你好, <uuid>". Once the token is live at the edge, /api/me carries the real X-Auth-Name: read
|
||||||
|
|||||||
@@ -129,8 +129,28 @@ async function brokerRefresh(refreshToken: string): Promise<BrokerRefreshResult>
|
|||||||
// (server-confirmed dead) lets the caller forceLogout, "transient" must keep state.
|
// (server-confirmed dead) lets the caller forceLogout, "transient" must keep state.
|
||||||
export type RefreshOutcome = "refreshed" | "invalid" | "transient";
|
export type RefreshOutcome = "refreshed" | "invalid" | "transient";
|
||||||
|
|
||||||
// refreshTokens renews the access token; api.ts calls it lazily on a 401.
|
// Single-flight guard. The broker rotates refresh tokens single-use: a refresh consumes
|
||||||
export async function refreshTokens(): Promise<RefreshOutcome>
|
// 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<RefreshOutcome> | 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<RefreshOutcome>
|
||||||
|
{
|
||||||
|
if (refreshInFlight) { return refreshInFlight; }
|
||||||
|
refreshInFlight = doRefreshTokens().finally(() => { refreshInFlight = null; });
|
||||||
|
return refreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRefreshTokens(): Promise<RefreshOutcome>
|
||||||
{
|
{
|
||||||
const store = useAppStore.getState();
|
const store = useAppStore.getState();
|
||||||
if (store.authMode !== "prod") { return "transient"; }
|
if (store.authMode !== "prod") { return "transient"; }
|
||||||
|
|||||||
@@ -429,4 +429,24 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
"ios.control.upload": "Upload Clipboard",
|
"ios.control.upload": "Upload Clipboard",
|
||||||
"ios.control.pull": "Pull 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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -425,4 +425,24 @@ export const zhCN = {
|
|||||||
"ios.control.upload": "上传剪贴板",
|
"ios.control.upload": "上传剪贴板",
|
||||||
"ios.control.pull": "拉取剪贴板",
|
"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 登录",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -429,4 +429,24 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
"ios.control.upload": "上傳剪貼簿",
|
"ios.control.upload": "上傳剪貼簿",
|
||||||
"ios.control.pull": "拉取剪貼簿",
|
"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 登入",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user