iOS 第四批反馈:隔夜重登修复 + 通知/红点/日志/扫码登他端/主屏小组件 + 长条行交互统一

- #7 隔夜被迫重新登录:auth.ts 加 refreshInFlight 单飞刷新。根因=broker 单次轮换 refresh token + 冷启并发 401(引擎 boot 同时 startHub/refreshSessionScope/healIOSIdentity/refreshICEServers,隔夜 access 已过期全部 401)各自用同一 refresh 刷新,首个轮换、其余 replay 失效旧 token → 401 invalid → forceLogout → 清 Keychain → 重登。单飞使并发 401 共享一次轮换。
- #2 通知系统重做:删 EngineController.deviceActionStatus/clipboardStatus 两字段及设备页/设置页内联灰字(串页、不清除);改瞬时横幅 NotificationBanner(NoticeOverlay 顶部覆盖,成功绿/失败红/信息中性、4s 自动消失、可点叉清除),设备移除/改名/剪贴板结果统一走 notify。
- #5 未读红点:EngineController unreadMessages/unreadTransfers/activeTab/seenIncomingTransfers + setActiveTab(进入对应 tab 清零)+ noteIncomingTransfer;RootView Tab .badge + onChange(selection) 同步当前页。
- #1 标签栏重排:传输 / 文件 / 消息 / 设备 / 设置(文件域相邻、设备归管理区近设置)。
- #3 日志独立界面:EngineController logs 环形缓冲(cap 300)+ clearLogs;新增 LogView(最近 N 条、分级配色、可选中、可清空),设置页引擎段「查看日志」NavigationLink。
- #8 扫码登录他端:iOS 此前仅能展示自身码被扫,缺扫描方批准能力。新增 ScannerView(VisionKit DataScannerViewController + 相机权限 + /link?r=&c= 校验);engine/main.ts 导入 qr.ts 加 fetchLoginRequest/approveLogin/denyLogin(qrRequest/qrApprove(full,persist)/qrDeny)+ 回报 loginRequest/loginApproved/loginDenied/loginFailed;EngineController pendingLoginRequest + 方法 + handleNotify;设备页工具栏「扫码登录设备」入口 + 批准确认(展示对端名/类型/IP)。
- #6 主屏小组件 + 应用外反馈:除控制中心两控件外,新增 CloudClipboardWidget(交互式主屏/锁屏小组件,上传/拉取按钮复用同一 AppIntent);新增 WidgetFeedback(App 外触发弹本地通知反馈);ClipboardIntents perform 加成功/失败/空/needApp 反馈。品牌色 Color.cdropAccent 从 RootView 移入 Shared/Theme(主 app 与扩展共用)。
- 长条行交互统一:设备 / 传输历史 / 收到的文件 一致为「点按打开 + 尾部滑动删除 + 长按菜单」。设备去掉「点按即删」、加长按移除;修滑动移除时「析构按钮预演删除动画致下方设备上移又复位」的抖动(改普通按钮 + 红色,真正删除在二次确认后);传输页 ScrollView→List 以获原生滑动删除,卡片观感经 .plain + 透明行背景 + 隐藏分隔线保留。
- i18n:3 locale 新增 ios.logs./ios.scan./ios.widget./ios.settings.viewLogs 等键并重生 iOS JSON。
This commit is contained in:
2026-06-27 15:53:54 +08:00
parent 127070e226
commit 1b94df1604
18 changed files with 1038 additions and 103 deletions
+14
View File
@@ -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)
})
}
+168 -16
View File
@@ -50,6 +50,38 @@ struct TransferItem: Identifiable, Equatable, Codable
var id: String { sessionId }
}
// #2 / /
// / deviceActionStatus / clipboardStatus
struct Notice: Identifiable, Equatable
{
enum Kind: Equatable { case success, error, info }
let id = UUID()
let kind: Kind
let text: String
}
// #3installLogBridge console.warn/error
// N lastEngineLogat
struct LogEntry: Identifiable, Equatable
{
let id = UUID()
let level: String // warn | error | info
let message: String
let at: Date
}
// #8/link?r=&c=
// / requestId / code
struct LoginRequestItem: Identifiable, Equatable
{
let requestId: String
let code: String
let deviceName: String
let deviceType: String
let requestIp: String
var id: String { requestId }
}
// JS web/src/net/ios.tsios/PLAN.md §2
// "cdropEngine" WKScriptMessageHandler { id, method, payload }RPC
// { notify, payload }RPC evaluateJavaScript
@@ -75,14 +107,28 @@ final class EngineController: NSObject
var presenceCount = 0
var lastEngineLog = ""
// #3log logsCap N
var logs: [LogEntry] = []
private static let logsCap = 300
// #2
var notices: [Notice] = []
// #8 / /
var pendingLoginRequest: LoginRequestItem?
// #5 / tab activeTab
// 使seenIncomingTransfers
//
var unreadMessages = 0
var unreadTransfers = 0
var activeTab = "transfer"
private var seenIncomingTransfers: Set<String> = []
// JS "ready" APNs
// didRegister ready flushPushRegistration
private var engineReady = false
// / /
var clipboardStatus = ""
var deviceActionStatus = ""
// WebRTC origin PLAN R-iOS-3线
// https CDROP_ENGINE_URL http://localhostlocalhost
// WebRTC Windows loopback
@@ -175,6 +221,14 @@ final class EngineController: NSObject
transfers = []
history = []
messages = []
// / / / /
notices = []
logs = []
unreadMessages = 0
unreadTransfers = 0
seenIncomingTransfers = []
activeTab = "transfer"
pendingLoginRequest = nil
//
RecordsStore.clear("history")
RecordsStore.clear("messages")
@@ -215,6 +269,53 @@ final class EngineController: NSObject
RecordsStore.clear("messages")
}
// ---- #2 ----
// notify 4s
//
func notify(_ text: String, kind: Notice.Kind)
{
guard !text.isEmpty else { return }
let notice = Notice(kind: kind, text: text)
notices.insert(notice, at: 0)
Task
{
try? await Task.sleep(for: .seconds(4))
dismissNotice(notice.id)
}
}
func dismissNotice(_ id: UUID)
{
notices.removeAll { $0.id == id }
}
// ---- #5 ----
// setActiveTab
func setActiveTab(_ tab: String)
{
activeTab = tab
if tab == "messages" { unreadMessages = 0 }
if tab == "transfer" { unreadTransfers = 0 }
}
// noteIncomingTransfer sessionId
//
private func noteIncomingTransfer(_ item: TransferItem)
{
guard item.direction == "incoming", !seenIncomingTransfers.contains(item.sessionId) else { return }
seenIncomingTransfers.insert(item.sessionId)
if activeTab != "transfer" { unreadTransfers += 1 }
}
// ---- #3 ----
func clearLogs()
{
logs.removeAll()
}
// Documents Files app /
func receivedFiles() -> [URL] { downloads.receivedFiles() }
func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) }
@@ -239,15 +340,13 @@ final class EngineController: NSObject
func uploadClipboard()
{
let content = UIPasteboard.general.string ?? ""
guard !content.isEmpty else { clipboardStatus = t("ios.clipboard.empty"); return }
clipboardStatus = t("ios.clipboard.uploading")
guard !content.isEmpty else { notify(t("ios.clipboard.empty"), kind: .info); return }
sendCommand("clipboardUpload", payload: [ "content": content ])
}
// UIPasteboard handleNotify "clipboard"
func pullClipboard()
{
clipboardStatus = t("ios.clipboard.pulling")
sendCommand("clipboardPull", payload: [:])
}
@@ -255,10 +354,38 @@ final class EngineController: NSObject
// #2
func revokeDevice(_ deviceID: String)
{
deviceActionStatus = ""
sendCommand("revokeDevice", payload: [ "device_id": deviceID ])
}
// ---- #8 ----
// /link?r=&c=qrRequest
// requestId / code
func fetchLoginRequest(requestId: String, code: String)
{
sendCommand("fetchLoginRequest", payload: [ "request_id": requestId, "code": code ])
}
// full访 Web/PWA
func approveLogin()
{
guard let req = pendingLoginRequest else { return }
pendingLoginRequest = nil
sendCommand("approveLogin", payload: [ "request_id": req.requestId, "code": req.code ])
}
func denyLogin()
{
guard let req = pendingLoginRequest else { return }
pendingLoginRequest = nil
sendCommand("denyLogin", payload: [ "request_id": req.requestId, "code": req.code ])
}
func cancelLoginRequest()
{
pendingLoginRequest = nil
}
// O CANCELLED transfers / transferDone
func cancelTransfer(_ sessionId: String)
{
@@ -507,6 +634,7 @@ extension EngineController: WKScriptMessageHandler
if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
{
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
for item in transfers { noteIncomingTransfer(item) } // #5
syncBackgroundTask()
}
case "message":
@@ -516,6 +644,7 @@ extension EngineController: WKScriptMessageHandler
let m = p["message"] as? [String: Any],
let item = Self.parseMessage(m)
{
let isNew = !messages.contains { $0.id == item.id }
messages.removeAll { $0.id == item.id }
messages.insert(item, at: 0)
if messages.count > Self.messagesCap
@@ -523,11 +652,14 @@ extension EngineController: WKScriptMessageHandler
messages.removeLast(messages.count - Self.messagesCap)
}
RecordsStore.save(messages, "messages")
// #5outgoing
if isNew, item.direction == "incoming", activeTab != "messages" { unreadMessages += 1 }
}
case "transferDone":
// sessionId
if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
{
noteIncomingTransfer(item) // #5 seen
transfers.removeAll { $0.sessionId == item.sessionId }
history.removeAll { $0.sessionId == item.sessionId }
history.insert(item, at: 0)
@@ -549,22 +681,42 @@ extension EngineController: WKScriptMessageHandler
if let p = payload as? [String: Any], let msg = p["msg"] as? String
{
lastEngineLog = msg
logs.insert(LogEntry(level: p["level"] as? String ?? "info", message: msg, at: Date()), at: 0)
if logs.count > Self.logsCap { logs.removeLast(logs.count - Self.logsCap) }
}
case "clipboard":
// UIPasteboard
if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
{
UIPasteboard.general.string = content
clipboardStatus = t("ios.clipboard.pulled")
notify(t("ios.clipboard.pulled"), kind: .success)
}
else
{
clipboardStatus = t("ios.clipboard.empty")
notify(t("ios.clipboard.empty"), kind: .info)
}
case "clipboardUploaded":
clipboardStatus = t("ios.clipboard.uploaded")
notify(t("ios.clipboard.uploaded"), kind: .success)
case "deviceRevoked":
deviceActionStatus = t("ios.devices.revoked")
notify(t("ios.devices.revoked"), kind: .success)
case "loginRequest":
// #8
if let p = payload as? [String: Any],
let rid = p["request_id"] as? String, let code = p["code"] as? String
{
pendingLoginRequest = LoginRequestItem(
requestId: rid, code: code,
deviceName: p["device_name"] as? String ?? "",
deviceType: p["device_type"] as? String ?? "",
requestIp: p["request_ip"] as? String ?? "")
}
case "loginApproved":
notify(t("ios.scan.approved"), kind: .success)
case "loginDenied":
notify(t("ios.scan.denied"), kind: .success)
case "loginFailed":
pendingLoginRequest = nil
notify(t("ios.scan.failed"), kind: .error)
case "identityUpdated":
// /api/me QR UUID + Keychain
// UUID#12
@@ -580,7 +732,7 @@ extension EngineController: WKScriptMessageHandler
DeviceNameStore.value = name
auth?.updateDeviceName(name)
deviceName = name
deviceActionStatus = t("ios.settings.deviceName.success")
notify(t("ios.settings.deviceName.success"), kind: .success)
}
case "error":
// error /
@@ -588,11 +740,11 @@ extension EngineController: WKScriptMessageHandler
{
let stage = p["stage"] as? String ?? ""
let msg = p["message"] as? String ?? ""
if stage == "clipboard" { clipboardStatus = t("ios.clipboard.failed") }
if stage == "clipboard" { notify(t("ios.clipboard.failed"), kind: .error) }
else if stage == "revoke"
{
deviceActionStatus = msg == "step_up_required"
? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed")
notify(msg == "step_up_required"
? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed"), kind: .error)
}
else { status = "错误:\(msg)" }
}
+94
View File
@@ -0,0 +1,94 @@
import SwiftUI
// #3 EngineController.logs N console.warn/error
// 便 Web Inspector SSE / /
// lastEngineLog
struct LogView: View
{
@Environment(EngineController.self) private var engine
var body: some View
{
Group
{
if engine.logs.isEmpty
{
ContentUnavailableView(t("ios.logs.empty"), systemImage: "doc.text")
}
else
{
List
{
ForEach(engine.logs)
{ entry in
logRow(entry)
}
}
.listStyle(.plain)
}
}
.navigationTitle(t("ios.logs.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar
{
if !engine.logs.isEmpty
{
ToolbarItem(placement: .topBarTrailing)
{
Button(role: .destructive) { engine.clearLogs() }
label: { Label(t("ios.logs.clear"), systemImage: "trash") }
}
}
}
}
private func logRow(_ entry: LogEntry) -> some View
{
VStack(alignment: .leading, spacing: 4)
{
HStack(spacing: 6)
{
Image(systemName: levelSymbol(entry.level))
.font(.caption)
.foregroundStyle(levelTint(entry.level))
Text(timeString(entry.at))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Text(entry.message)
.font(.caption.monospaced())
.foregroundStyle(.primary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 2)
}
private func levelSymbol(_ level: String) -> String
{
switch level
{
case "error": return "xmark.octagon.fill"
case "warn": return "exclamationmark.triangle.fill"
default: return "info.circle"
}
}
private func levelTint(_ level: String) -> Color
{
switch level
{
case "error": return .red
case "warn": return .orange
default: return .secondary
}
}
private func timeString(_ date: Date) -> String
{
let f = DateFormatter()
f.dateFormat = "HH:mm:ss"
return f.string(from: date)
}
}
@@ -0,0 +1,80 @@
import SwiftUI
// #2绿 / /
// EngineController.notify 4s /
// deviceActionStatus / clipboardStatus EngineController.notices
struct NoticeOverlay: View
{
@Environment(EngineController.self) private var engine
var body: some View
{
VStack(spacing: 8)
{
ForEach(engine.notices)
{ notice in
NoticeBanner(notice: notice) { engine.dismissNotice(notice.id) }
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.animation(.spring(duration: 0.3), value: engine.notices)
//
.allowsHitTesting(!engine.notices.isEmpty)
}
}
private struct NoticeBanner: View
{
let notice: Notice
let onDismiss: () -> Void
var body: some View
{
HStack(spacing: 10)
{
Image(systemName: symbol)
.font(.headline)
.foregroundStyle(tint)
Text(notice.text)
.font(.subheadline)
.foregroundStyle(.primary)
.lineLimit(2)
Spacer(minLength: 8)
Button { onDismiss() }
label:
{
Image(systemName: "xmark")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding(.vertical, 12)
.padding(.horizontal, 14)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(tint.opacity(0.35), lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 8, y: 2)
}
private var symbol: String
{
switch notice.kind
{
case .success: return "checkmark.circle.fill"
case .error: return "exclamationmark.triangle.fill"
case .info: return "info.circle.fill"
}
}
private var tint: Color
{
switch notice.kind
{
case .success: return .green
case .error: return .red
case .info: return .cdropAccent
}
}
}
+21 -1
View File
@@ -374,5 +374,25 @@
"ios.share.title": "Send {{n}} file(s) to a device",
"ios.control.upload": "Upload Clipboard",
"ios.control.pull": "Pull Clipboard",
"ios.control.sessionLabel": "{{name}} · Control"
"ios.control.sessionLabel": "{{name}} · Control",
"ios.settings.viewLogs": "View Logs",
"ios.logs.title": "Engine Logs",
"ios.logs.empty": "No logs yet",
"ios.logs.clear": "Clear Logs",
"ios.logs.copy": "Copy All",
"ios.scan.entry": "Scan to Sign In",
"ios.scan.title": "Scan QR Code",
"ios.scan.hint": "Point at the sign-in QR code on another device",
"ios.scan.invalid": "Unrecognized sign-in QR code",
"ios.scan.approveTitle": "Approve this device to sign in?",
"ios.scan.approve": "Approve",
"ios.scan.deny": "Deny",
"ios.scan.approved": "Device approved",
"ios.scan.denied": "Device denied",
"ios.scan.failed": "Action failed, please retry",
"ios.scan.cameraDenied": "Camera access is required to scan; enable it in Settings",
"ios.scan.requestIp": "Source IP",
"ios.widget.clipboardName": "Cloud Clipboard",
"ios.widget.clipboardDesc": "Quickly upload or pull the shared clipboard",
"ios.widget.needApp": "Open the app to sign in first"
}
+21 -1
View File
@@ -374,5 +374,25 @@
"ios.share.title": "发送 {{n}} 个文件到设备",
"ios.control.upload": "上传剪贴板",
"ios.control.pull": "拉取剪贴板",
"ios.control.sessionLabel": "{{name}} · 控件"
"ios.control.sessionLabel": "{{name}} · 控件",
"ios.settings.viewLogs": "查看日志",
"ios.logs.title": "引擎日志",
"ios.logs.empty": "暂无日志",
"ios.logs.clear": "清空日志",
"ios.logs.copy": "复制全部",
"ios.scan.entry": "扫码登录设备",
"ios.scan.title": "扫码登录",
"ios.scan.hint": "对准另一台设备上的登录二维码",
"ios.scan.invalid": "无法识别的登录二维码",
"ios.scan.approveTitle": "批准此设备登录本账号?",
"ios.scan.approve": "批准登录",
"ios.scan.deny": "拒绝",
"ios.scan.approved": "已批准设备登录",
"ios.scan.denied": "已拒绝设备登录",
"ios.scan.failed": "操作失败,请重试",
"ios.scan.cameraDenied": "需要相机权限才能扫码,请在系统设置中开启",
"ios.scan.requestIp": "来源 IP",
"ios.widget.clipboardName": "云剪贴板",
"ios.widget.clipboardDesc": "快速上传或拉取设备间剪贴板",
"ios.widget.needApp": "请先打开 App 登录"
}
+21 -1
View File
@@ -374,5 +374,25 @@
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
"ios.control.upload": "上傳剪貼簿",
"ios.control.pull": "拉取剪貼簿",
"ios.control.sessionLabel": "{{name}} · 控制項"
"ios.control.sessionLabel": "{{name}} · 控制項",
"ios.settings.viewLogs": "檢視日誌",
"ios.logs.title": "引擎日誌",
"ios.logs.empty": "尚無日誌",
"ios.logs.clear": "清空日誌",
"ios.logs.copy": "複製全部",
"ios.scan.entry": "掃碼登入裝置",
"ios.scan.title": "掃碼登入",
"ios.scan.hint": "對準另一台裝置上的登入 QR 碼",
"ios.scan.invalid": "無法辨識的登入 QR 碼",
"ios.scan.approveTitle": "批准此裝置登入本帳號?",
"ios.scan.approve": "批准登入",
"ios.scan.deny": "拒絕",
"ios.scan.approved": "已批准裝置登入",
"ios.scan.denied": "已拒絕裝置登入",
"ios.scan.failed": "操作失敗,請重試",
"ios.scan.cameraDenied": "需要相機權限才能掃碼,請在系統設定中開啟",
"ios.scan.requestIp": "來源 IP",
"ios.widget.clipboardName": "雲剪貼簿",
"ios.widget.clipboardDesc": "快速上傳或拉取裝置間剪貼簿",
"ios.widget.needApp": "請先開啟 App 登入"
}
+148 -76
View File
@@ -20,6 +20,8 @@ struct RootView: View
{
TabView(selection: $selection)
{
// #1
// / #5
Tab(t("ios.tab.transfer"), systemImage: "arrow.up.arrow.down", value: "transfer")
{
NavigationStack
@@ -27,11 +29,12 @@ struct RootView: View
TransferListView()
}
}
Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices")
.badge(engine.unreadTransfers)
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
{
NavigationStack
{
DeviceListView()
ReceivedFilesView()
}
}
Tab(t("ios.tab.messages"), systemImage: "message", value: "messages")
@@ -41,11 +44,12 @@ struct RootView: View
MessagesView()
}
}
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
.badge(engine.unreadMessages)
Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices")
{
NavigationStack
{
ReceivedFilesView()
DeviceListView()
}
}
Tab(t("ios.tab.settings"), systemImage: "gearshape", value: "settings")
@@ -58,6 +62,13 @@ struct RootView: View
}
.tabBarMinimizeBehavior(.onScrollDown)
.tint(.cdropAccent)
// #2
.overlay(alignment: .top)
{
NoticeOverlay()
}
// #5
.onChange(of: selection) { engine.setActiveTab(selection) }
.background
{
EngineWebView(controller: engine, auth: auth)
@@ -95,6 +106,9 @@ struct TransferListView: View
@State private var showDevicePicker = false
@State private var showNoDevices = false
@State private var showClearConfirm = false
// List NavigationLink chevron
// navigationDestination(item:) sessionIdString Hashable
@State private var detailSession: String?
// 线 EngineController.sendableDevices
private var sendableDevices: [DeviceItem]
@@ -104,44 +118,19 @@ struct TransferListView: View
var body: some View
{
ScrollView
Group
{
if engine.transfers.isEmpty && engine.history.isEmpty
{
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
.padding(.top, 80)
}
else
{
VStack(spacing: 16)
{
if !engine.transfers.isEmpty
{
sectionHeader(t("home.transfer.active"))
ForEach(engine.transfers)
{ item in
transferLink(item)
}
}
if !engine.history.isEmpty
{
sectionHeader(t("home.transfer.history"))
ForEach(engine.history)
{ item in
transferLink(item)
.contextMenu
{
Button(role: .destructive)
{ engine.deleteTransferRecord(item.sessionId) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
}
}
}
.padding()
transferList
}
}
.navigationTitle(t("ios.tab.transfer"))
.navigationDestination(item: $detailSession) { sid in TransferDetailView(sessionId: sid) }
.toolbar
{
if !engine.history.isEmpty
@@ -201,6 +190,32 @@ struct TransferListView: View
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
}
// List ScrollView / +
// .plain + + 线 body
private var transferList: some View
{
List
{
if !engine.transfers.isEmpty
{
Section
{
ForEach(engine.transfers) { transferRow($0, history: false) }
}
header: { sectionHeader(t("home.transfer.active")) }
}
if !engine.history.isEmpty
{
Section
{
ForEach(engine.history) { transferRow($0, history: true) }
}
header: { sectionHeader(t("home.transfer.history")) }
}
}
.listStyle(.plain)
}
private func sectionHeader(_ title: String) -> some View
{
HStack
@@ -212,17 +227,36 @@ struct TransferListView: View
}
}
private func transferLink(_ item: TransferItem) -> some View
// + /
// /
//
@ViewBuilder
private func transferRow(_ item: TransferItem, history: Bool) -> some View
{
NavigationLink
let card = TransferCardView(item: item)
.contentShape(Rectangle())
.onTapGesture { detailSession = item.sessionId }
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
if history
{
TransferDetailView(sessionId: item.sessionId)
card
.swipeActions(edge: .trailing)
{
Button(role: .destructive) { engine.deleteTransferRecord(item.sessionId) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
.contextMenu
{
Button(role: .destructive) { engine.deleteTransferRecord(item.sessionId) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
}
label:
else
{
TransferCardView(item: item)
card
}
.buttonStyle(.plain)
}
private func startSend(to device: String)
@@ -424,6 +458,7 @@ struct DeviceListView: View
@Environment(EngineController.self) private var engine
@State private var revokeTarget: DeviceItem?
@State private var showRevoke = false
@State private var showScanner = false
//
private var allDevices: [DeviceItem]
@@ -451,18 +486,24 @@ struct DeviceListView: View
{
List
{
if !engine.deviceActionStatus.isEmpty
{
Text(engine.deviceActionStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(allDevices)
{ dev in
deviceRow(dev)
.contentShape(Rectangle())
.onTapGesture { if !isSelf(dev) { revokeTarget = dev; showRevoke = true } }
// +
.swipeActions(edge: .trailing)
{
if !isSelf(dev)
{
// destructive
// +
// 线
Button { revokeTarget = dev; showRevoke = true }
label: { Label(t("ios.devices.remove"), systemImage: "trash") }
.tint(.red)
}
}
.contextMenu
{
if !isSelf(dev)
{
@@ -475,6 +516,19 @@ struct DeviceListView: View
}
}
.navigationTitle(t("ios.tab.devices"))
.toolbar
{
// #8
ToolbarItem(placement: .topBarTrailing)
{
Button { showScanner = true }
label: { Label(t("ios.scan.entry"), systemImage: "qrcode.viewfinder") }
}
}
.sheet(isPresented: $showScanner)
{
ScanSheet { requestId, code in engine.fetchLoginRequest(requestId: requestId, code: code) }
}
.confirmationDialog(revokeTarget?.name ?? "", isPresented: $showRevoke, titleVisibility: .visible)
{
Button(t("ios.devices.remove"), role: .destructive)
@@ -483,6 +537,40 @@ struct DeviceListView: View
}
Button(t("common.cancel"), role: .cancel) { }
}
// #8 / / IP
.confirmationDialog(
t("ios.scan.approveTitle"),
isPresented: Binding(
get: { engine.pendingLoginRequest != nil },
set: { if !$0 { engine.cancelLoginRequest() } }),
titleVisibility: .visible)
{
Button(t("ios.scan.approve")) { engine.approveLogin() }
Button(t("ios.scan.deny"), role: .destructive) { engine.denyLogin() }
Button(t("common.cancel"), role: .cancel) { engine.cancelLoginRequest() }
}
message:
{
if let req = engine.pendingLoginRequest
{
Text(loginRequestInfo(req))
}
}
}
// · IP
private func loginRequestInfo(_ req: LoginRequestItem) -> String
{
var head: [String] = []
if !req.deviceName.isEmpty { head.append(req.deviceName) }
if !req.deviceType.isEmpty { head.append(req.deviceType) }
var s = head.joined(separator: " · ")
if !req.requestIp.isEmpty
{
if !s.isEmpty { s += "\n" }
s += "\(t("ios.scan.requestIp")): \(req.requestIp)"
}
return s
}
private func deviceRow(_ dev: DeviceItem) -> some View
@@ -537,12 +625,6 @@ struct SettingsView: View
TextField(t("ios.settings.deviceName"), text: $deviceNameDraft)
.submitLabel(.done)
.onSubmit { commitRename() }
if !engine.deviceActionStatus.isEmpty
{
Text(engine.deviceActionStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
Button(role: .destructive) { logout() }
label:
{
@@ -566,17 +648,23 @@ struct SettingsView: View
LabeledContent(t("ios.settings.signaling"), value: signalingState)
LabeledContent(t("ios.settings.presenceEvents"), value: "\(engine.presenceCount)")
LabeledContent(t("ios.settings.deviceCount"), value: "\(engine.devices.count)")
if !engine.lastEngineLog.isEmpty
// #3 N
NavigationLink
{
LogView()
}
label:
{
VStack(alignment: .leading, spacing: 2)
{
Text(t("ios.settings.lastLog"))
.font(.caption)
.foregroundStyle(.secondary)
Text(engine.lastEngineLog)
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
Text(t("ios.settings.viewLogs"))
if !engine.lastEngineLog.isEmpty
{
Text(engine.lastEngineLog)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
@@ -586,12 +674,6 @@ struct SettingsView: View
label: { Label(t("ios.clipboard.upload"), systemImage: "arrow.up.doc.on.clipboard") }
Button { engine.pullClipboard() }
label: { Label(t("ios.clipboard.pull"), systemImage: "arrow.down.doc") }
if !engine.clipboardStatus.isEmpty
{
Text(engine.clipboardStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
}
header:
{
@@ -800,14 +882,4 @@ private func deviceSymbol(_ type: String) -> String
return "desktopcomputer"
}
// web accent #644AC9 / #9580FF Asset
// Catalog Color Set UIColor
extension Color
{
static let cdropAccent = Color(uiColor: UIColor
{ trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.584, green: 0.502, blue: 1.0, alpha: 1)
: UIColor(red: 0.392, green: 0.290, blue: 0.788, alpha: 1)
})
}
// Color.cdropAccent Shared/Theme.swift app /
+165
View File
@@ -0,0 +1,165 @@
import AVFoundation
import SwiftUI
import VisionKit
// #8/link?r=&c=
// requestId / code VisionKit DataScannerViewController UI
// AVFoundation isSupported
struct ScanSheet: View
{
// (requestId, code)
let onScanned: (String, String) -> Void
@Environment(\.dismiss) private var dismiss
// niltrue/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 -1
View File
@@ -3,7 +3,8 @@ import SwiftUI
import WidgetKit
// iOS 18+ ControlWidgetPLAN C / §I4 /
// t() i18n bundle
// t() i18n bundle
// CloudClipboardWidget#6 / /
@main
struct CDropWidgetBundle: WidgetBundle
{
@@ -11,6 +12,7 @@ struct CDropWidgetBundle: WidgetBundle
{
UploadClipboardControl()
PullClipboardControl()
CloudClipboardWidget()
}
}
+40 -5
View File
@@ -1,7 +1,8 @@
import AppIntents
import UIKit
//
//
// intentApp 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()
}
@@ -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 ])
}
}
+19
View File
@@ -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)
}
}