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:
@@ -3,7 +3,8 @@ import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// 控制中心两控件(iOS 18+ ControlWidget,PLAN 决策 C / §I4):上传 / 拉取云剪贴板,零推送、
|
||||
// 用户主动触发。控件标签经 t() 跟随设备语言(i18n 单一真源,随扩展 bundle 一并打包)。
|
||||
// 用户主动触发。控件标签经 t() 跟随设备语言(i18n 单一真源,随扩展 bundle 一并打包)。另含主屏交互式
|
||||
// 小组件 CloudClipboardWidget(#6):同样的上传 / 拉取,可放主屏 / 锁屏。
|
||||
@main
|
||||
struct CDropWidgetBundle: WidgetBundle
|
||||
{
|
||||
@@ -11,6 +12,7 @@ struct CDropWidgetBundle: WidgetBundle
|
||||
{
|
||||
UploadClipboardControl()
|
||||
PullClipboardControl()
|
||||
CloudClipboardWidget()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import AppIntents
|
||||
import UIKit
|
||||
|
||||
// 「上传剪贴板」控件动作:读本机剪贴板文本,经控件专用会话推到云剪贴板。
|
||||
// 「上传剪贴板」动作:读本机剪贴板文本,经控件专用会话推到云剪贴板。控制中心控件与主屏小组件按钮
|
||||
// 共用此 intent。App 外触发时经 WidgetFeedback 弹本地通知反馈成功 / 失败(#6)。
|
||||
struct UploadClipboardIntent: AppIntent
|
||||
{
|
||||
static var title: LocalizedStringResource = "上传剪贴板"
|
||||
@@ -10,12 +11,29 @@ struct UploadClipboardIntent: AppIntent
|
||||
func perform() async throws -> some IntentResult
|
||||
{
|
||||
let text = await MainActor.run { UIPasteboard.general.string ?? "" }
|
||||
if !text.isEmpty { try? await ClipboardClient.upload(text) }
|
||||
guard !text.isEmpty else
|
||||
{
|
||||
await WidgetFeedback.post(t("ios.clipboard.empty"))
|
||||
return .result()
|
||||
}
|
||||
do
|
||||
{
|
||||
try await ClipboardClient.upload(text)
|
||||
await WidgetFeedback.post(t("ios.clipboard.uploaded"))
|
||||
}
|
||||
catch ClipboardClient.ClientError.noSession
|
||||
{
|
||||
await WidgetFeedback.post(t("ios.widget.needApp"))
|
||||
}
|
||||
catch
|
||||
{
|
||||
await WidgetFeedback.post(t("ios.clipboard.failed"))
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// 「拉取剪贴板」控件动作:拉云剪贴板内容写入本机剪贴板。
|
||||
// 「拉取剪贴板」动作:拉云剪贴板内容写入本机剪贴板。同上,App 外触发弹本地通知反馈。
|
||||
struct PullClipboardIntent: AppIntent
|
||||
{
|
||||
static var title: LocalizedStringResource = "拉取剪贴板"
|
||||
@@ -23,9 +41,26 @@ struct PullClipboardIntent: AppIntent
|
||||
|
||||
func perform() async throws -> some IntentResult
|
||||
{
|
||||
if let text = try? await ClipboardClient.pull(), !text.isEmpty
|
||||
do
|
||||
{
|
||||
await MainActor.run { UIPasteboard.general.string = text }
|
||||
let text = try await ClipboardClient.pull()
|
||||
if text.isEmpty
|
||||
{
|
||||
await WidgetFeedback.post(t("ios.clipboard.empty"))
|
||||
}
|
||||
else
|
||||
{
|
||||
await MainActor.run { UIPasteboard.general.string = text }
|
||||
await WidgetFeedback.post(t("ios.clipboard.pulled"))
|
||||
}
|
||||
}
|
||||
catch ClipboardClient.ClientError.noSession
|
||||
{
|
||||
await WidgetFeedback.post(t("ios.widget.needApp"))
|
||||
}
|
||||
catch
|
||||
{
|
||||
await WidgetFeedback.post(t("ios.clipboard.failed"))
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user