1b94df1604
- #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。
886 lines
34 KiB
Swift
886 lines
34 KiB
Swift
import PhotosUI
|
||
import SwiftUI
|
||
import UIKit
|
||
import UniformTypeIdentifiers
|
||
|
||
// 液态玻璃原生壳:底部标签栏 + 导航栈(iOS 26 SDK 自动玻璃化)。离屏无头引擎 WebView 挂在
|
||
// 背景里保持存活、零尺寸不可见。内容卡片属内容层不加玻璃;悬浮主操作用玻璃强调按钮(功能
|
||
// 层)。见 ios/PLAN.md §8.1。用户可见文案全走 i18n(t(),源=web catalog)。设备 / 传输
|
||
// 列表由引擎经桥推来的真实 presence / 传输态驱动(EngineController,arch A)。
|
||
struct RootView: View
|
||
{
|
||
@Environment(EngineController.self) private var engine
|
||
// auth 经环境注入(AppRoot 已 .environment(auth)),用于把会话源在 EngineWebView.makeUIView
|
||
// 里同步接给引擎——早于 boot 注入,消除冷启重开的「missing injected session」竞态(#11)。
|
||
@Environment(AuthManager.self) private var auth
|
||
// 初始标签可经 CDROP_TAB 环境变量指定(本机截图测试用:直接进设置看引擎状态)。
|
||
@State private var selection = ProcessInfo.processInfo.environment["CDROP_TAB"] ?? "transfer"
|
||
|
||
var body: some View
|
||
{
|
||
TabView(selection: $selection)
|
||
{
|
||
// 顺序按领域与频次分组(#1):文件域(传输 → 收到的文件)相邻,其次消息,最后管理区
|
||
// (设备 → 设置)。传输 / 消息带未读红点(#5),对应页激活即清零。
|
||
Tab(t("ios.tab.transfer"), systemImage: "arrow.up.arrow.down", value: "transfer")
|
||
{
|
||
NavigationStack
|
||
{
|
||
TransferListView()
|
||
}
|
||
}
|
||
.badge(engine.unreadTransfers)
|
||
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
|
||
{
|
||
NavigationStack
|
||
{
|
||
ReceivedFilesView()
|
||
}
|
||
}
|
||
Tab(t("ios.tab.messages"), systemImage: "message", value: "messages")
|
||
{
|
||
NavigationStack
|
||
{
|
||
MessagesView()
|
||
}
|
||
}
|
||
.badge(engine.unreadMessages)
|
||
Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices")
|
||
{
|
||
NavigationStack
|
||
{
|
||
DeviceListView()
|
||
}
|
||
}
|
||
Tab(t("ios.tab.settings"), systemImage: "gearshape", value: "settings")
|
||
{
|
||
NavigationStack
|
||
{
|
||
SettingsView()
|
||
}
|
||
}
|
||
}
|
||
.tabBarMinimizeBehavior(.onScrollDown)
|
||
.tint(.cdropAccent)
|
||
// 瞬时通知横幅覆盖在内容顶部(#2)。
|
||
.overlay(alignment: .top)
|
||
{
|
||
NoticeOverlay()
|
||
}
|
||
// 标签切换时同步当前页给引擎,驱动未读清零(#5)。
|
||
.onChange(of: selection) { engine.setActiveTab(selection) }
|
||
.background
|
||
{
|
||
EngineWebView(controller: engine, auth: auth)
|
||
.frame(width: 0, height: 0)
|
||
.opacity(0)
|
||
.allowsHitTesting(false)
|
||
}
|
||
// Share Extension 交接:cdrop://share 唤起后导入收件箱待发文件,弹出选设备发送。
|
||
.onAppear { engine.loadPendingShares() }
|
||
.confirmationDialog(
|
||
t("ios.share.title", [ "n": String(engine.pendingShareFiles.count) ]),
|
||
isPresented: Binding(
|
||
get: { !engine.pendingShareFiles.isEmpty },
|
||
set: { if !$0 { engine.dismissShares() } }
|
||
),
|
||
titleVisibility: .visible
|
||
)
|
||
{
|
||
ForEach(engine.sendableDevices())
|
||
{ dev in
|
||
Button(dev.name) { engine.sendShares(to: dev.name) }
|
||
}
|
||
Button(t("common.cancel"), role: .cancel) { engine.dismissShares() }
|
||
}
|
||
}
|
||
}
|
||
|
||
struct TransferListView: View
|
||
{
|
||
@Environment(EngineController.self) private var engine
|
||
@State private var showImporter = false
|
||
@State private var showPhotoPicker = false
|
||
@State private var photoItem: PhotosPickerItem?
|
||
@State private var pickedURL: URL?
|
||
@State private var showDevicePicker = false
|
||
@State private var showNoDevices = false
|
||
@State private var showClearConfirm = false
|
||
// 点按传输卡片进详情:不在 List 内用 NavigationLink(会出现 chevron 改变卡片观感),改经
|
||
// navigationDestination(item:) 程序化推栈(按 sessionId,String 即 Hashable 标识),卡片保持原样。
|
||
@State private var detailSession: String?
|
||
|
||
// 可发送目标:在线、且非本机(共用 EngineController.sendableDevices)。
|
||
private var sendableDevices: [DeviceItem]
|
||
{
|
||
engine.sendableDevices()
|
||
}
|
||
|
||
var body: some View
|
||
{
|
||
Group
|
||
{
|
||
if engine.transfers.isEmpty && engine.history.isEmpty
|
||
{
|
||
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
|
||
}
|
||
else
|
||
{
|
||
transferList
|
||
}
|
||
}
|
||
.navigationTitle(t("ios.tab.transfer"))
|
||
.navigationDestination(item: $detailSession) { sid in TransferDetailView(sessionId: sid) }
|
||
.toolbar
|
||
{
|
||
if !engine.history.isEmpty
|
||
{
|
||
ToolbarItem(placement: .topBarTrailing)
|
||
{
|
||
Button(role: .destructive) { showClearConfirm = true }
|
||
label: { Label(t("ios.records.clear"), systemImage: "trash") }
|
||
}
|
||
}
|
||
}
|
||
.confirmationDialog(t("ios.records.clearConfirm"), isPresented: $showClearConfirm, titleVisibility: .visible)
|
||
{
|
||
Button(t("ios.records.clear"), role: .destructive) { engine.clearHistory() }
|
||
Button(t("common.cancel"), role: .cancel) { }
|
||
}
|
||
.overlay(alignment: .bottomTrailing)
|
||
{
|
||
// 发送入口:文件(Files / 文档)或图库(Photos)。图库走 PhotosPicker,选中后取字节落
|
||
// 临时文件再复用既有发送链路(#14)。
|
||
Menu
|
||
{
|
||
Button { showImporter = true }
|
||
label: { Label(t("ios.send.fromFiles"), systemImage: "folder") }
|
||
Button { showPhotoPicker = true }
|
||
label: { Label(t("ios.send.fromPhotos"), systemImage: "photo.on.rectangle") }
|
||
}
|
||
label:
|
||
{
|
||
Image(systemName: "paperplane.fill")
|
||
.font(.title2)
|
||
.padding(18)
|
||
}
|
||
.buttonStyle(.glassProminent)
|
||
.padding(24)
|
||
}
|
||
.fileImporter(isPresented: $showImporter, allowedContentTypes: [ .item ])
|
||
{ result in
|
||
if case .success(let url) = result
|
||
{
|
||
pickedURL = url
|
||
if sendableDevices.isEmpty { showNoDevices = true }
|
||
else { showDevicePicker = true }
|
||
}
|
||
}
|
||
.photosPicker(isPresented: $showPhotoPicker, selection: $photoItem,
|
||
matching: .any(of: [ .images, .videos ]))
|
||
.onChange(of: photoItem) { loadPhoto() }
|
||
.confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
|
||
{
|
||
ForEach(sendableDevices)
|
||
{ dev in
|
||
Button(dev.name) { startSend(to: dev.name) }
|
||
}
|
||
Button(t("common.cancel"), role: .cancel) { }
|
||
}
|
||
.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
|
||
{
|
||
Text(title)
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
// 传输行:卡片观感不变;点按进详情;历史记录支持滑动删除 + 长按删除(与设备 / 文件长条一致)。
|
||
// 进行中的传输不挂删除手势(取消 / 切中继在详情页)。删除是真删除(无需二次确认),故析构滑动按钮
|
||
// 的删除动画与实际一致、不会抖动。
|
||
@ViewBuilder
|
||
private func transferRow(_ item: TransferItem, history: Bool) -> some View
|
||
{
|
||
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
|
||
{
|
||
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") }
|
||
}
|
||
}
|
||
else
|
||
{
|
||
card
|
||
}
|
||
}
|
||
|
||
private func startSend(to device: String)
|
||
{
|
||
guard let url = pickedURL else { return }
|
||
engine.sendFile(to: device, fileURL: url)
|
||
pickedURL = nil
|
||
}
|
||
|
||
// 图库选中后:异步取字节 → 落临时文件 → 走与文件相同的「选设备发送」流程(#14)。
|
||
private func loadPhoto()
|
||
{
|
||
guard let item = photoItem else { return }
|
||
Task
|
||
{
|
||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||
let url = engine.stagePhotoData(data, suggestedName: suggestedPhotoName(item))
|
||
else { return }
|
||
await MainActor.run
|
||
{
|
||
pickedURL = url
|
||
photoItem = nil
|
||
if sendableDevices.isEmpty { showNoDevices = true }
|
||
else { showDevicePicker = true }
|
||
}
|
||
}
|
||
}
|
||
|
||
// 据所选项的内容类型推一个带正确扩展名的文件名(图片默认 jpg,视频默认 mov)。
|
||
private func suggestedPhotoName(_ item: PhotosPickerItem) -> String
|
||
{
|
||
let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg"
|
||
return "photo-\(UUID().uuidString.prefix(8)).\(ext)"
|
||
}
|
||
}
|
||
|
||
// 传输卡片:内容层,用语义材质背景而非玻璃(玻璃专属功能 / 导航层)。状态 / 阶段 / 速率
|
||
// 文案全部复用共享 transfer.* catalog(强约束②)。
|
||
struct TransferCardView: View
|
||
{
|
||
let item: TransferItem
|
||
|
||
var body: some View
|
||
{
|
||
VStack(alignment: .leading, spacing: 8)
|
||
{
|
||
HStack(spacing: 6)
|
||
{
|
||
Image(systemName: item.direction == "incoming"
|
||
? "arrow.down.circle" : "arrow.up.circle")
|
||
.foregroundStyle(.secondary)
|
||
Text(item.fileName)
|
||
.font(.headline)
|
||
.lineLimit(1)
|
||
Spacer()
|
||
}
|
||
if isActive(item)
|
||
{
|
||
ProgressView(value: progressOf(item))
|
||
}
|
||
Text(detailLine)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding()
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||
}
|
||
|
||
// 卡片副行:活跃时显示阶段,否则显示终态;附文件大小,活跃且有速率时附速度。
|
||
private var detailLine: String
|
||
{
|
||
var parts: [String] = []
|
||
if isActive(item), let phase = item.phase
|
||
{
|
||
parts.append(t("transfer.phase.\(phase)"))
|
||
}
|
||
else
|
||
{
|
||
parts.append(t("transfer.state.\(item.state)"))
|
||
}
|
||
parts.append(formatBytes(item.fileSize))
|
||
if isActive(item), let rate = item.bytesPerSec, rate > 0
|
||
{
|
||
parts.append(t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
|
||
}
|
||
return parts.joined(separator: " · ")
|
||
}
|
||
}
|
||
|
||
// 传输详情(TODO #5):按 sessionId 从引擎实时态查当前记录,故进度 / 速率随传输刷新;
|
||
// 传输完成后从 transfers 移入 history,仍按 sessionId 命中。
|
||
struct TransferDetailView: View
|
||
{
|
||
@Environment(EngineController.self) private var engine
|
||
let sessionId: String
|
||
@State private var previewItem: PreviewItem?
|
||
|
||
private var item: TransferItem?
|
||
{
|
||
engine.transfers.first { $0.sessionId == sessionId }
|
||
?? engine.history.first { $0.sessionId == sessionId }
|
||
}
|
||
|
||
var body: some View
|
||
{
|
||
Group
|
||
{
|
||
if let item
|
||
{
|
||
List
|
||
{
|
||
// 进行中的传输:主动取消 / 立即切中继(O)。已是中继则不再显示「立即中继」。
|
||
if isActive(item)
|
||
{
|
||
Section
|
||
{
|
||
Button(role: .destructive) { engine.cancelTransfer(item.sessionId) }
|
||
label: { Label(t("ios.transfer.cancel"), systemImage: "xmark.circle") }
|
||
if item.mode != "relay"
|
||
{
|
||
Button { engine.switchToRelay(item.sessionId) }
|
||
label: { Label(t("ios.transfer.forceRelay"),
|
||
systemImage: "antenna.radiowaves.left.and.right") }
|
||
}
|
||
}
|
||
}
|
||
Section
|
||
{
|
||
LabeledContent(t("ios.detail.direction"),
|
||
value: item.direction == "incoming"
|
||
? t("ios.transfer.incoming") : t("ios.transfer.outgoing"))
|
||
LabeledContent(t("ios.detail.peer"), value: item.peerName)
|
||
LabeledContent(t("ios.detail.state"), value: t("transfer.state.\(item.state)"))
|
||
if let phase = item.phase
|
||
{
|
||
LabeledContent(t("ios.detail.phase"), value: t("transfer.phase.\(phase)"))
|
||
}
|
||
if let mode = item.mode, !mode.isEmpty
|
||
{
|
||
LabeledContent(t("ios.detail.channel"), value: t("transfer.mode.\(mode)"))
|
||
}
|
||
}
|
||
Section
|
||
{
|
||
LabeledContent(t("ios.detail.size"), value: formatBytes(item.fileSize))
|
||
if let sent = item.bytesTransferred
|
||
{
|
||
LabeledContent(t("ios.detail.progress"),
|
||
value: "\(formatBytes(sent)) / \(formatBytes(item.fileSize))")
|
||
}
|
||
if let rate = item.bytesPerSec, rate > 0
|
||
{
|
||
LabeledContent(t("ios.detail.speed"),
|
||
value: t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
|
||
}
|
||
ProgressView(value: progressOf(item))
|
||
}
|
||
// 收到的文件「直达」:完成的接收传输可直接打开对应文件。
|
||
if item.direction == "incoming", item.state == "DONE",
|
||
let url = engine.receivedFile(matching: item.fileName)
|
||
{
|
||
Section
|
||
{
|
||
Button { previewItem = PreviewItem(url: url) }
|
||
label: { Label(t("ios.detail.openFile"), systemImage: "doc") }
|
||
}
|
||
}
|
||
// ICE 连接诊断:选中候选对含 "relay" 即 P2P 实走 TURN 中继(解释慢速)。
|
||
if item.iceConn != nil || item.iceLocal != nil
|
||
{
|
||
Section
|
||
{
|
||
if let conn = item.iceConn
|
||
{
|
||
LabeledContent(t("transfer.debug.iceConnection"), value: conn)
|
||
}
|
||
if let local = item.iceLocal, let remote = item.iceRemote
|
||
{
|
||
LabeledContent(t("transfer.debug.selectedPair"), value: "\(local) ⇄ \(remote)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
|
||
}
|
||
}
|
||
.navigationTitle(item?.fileName ?? t("ios.detail.title"))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.sheet(item: $previewItem) { item in FilePreviewSheet(item: item) }
|
||
}
|
||
}
|
||
|
||
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]
|
||
{
|
||
engine.devices.sorted { a, b in isSelf(a) && !isSelf(b) }
|
||
}
|
||
|
||
// 判本机按稳定 device_id(与设备名解耦——改名后仍判得准);selfDeviceID 缺失时回落按名。
|
||
private func isSelf(_ dev: DeviceItem) -> Bool
|
||
{
|
||
let selfID = engine.selfDeviceID
|
||
if !selfID.isEmpty, !dev.deviceID.isEmpty { return dev.deviceID == selfID }
|
||
return dev.name == engine.deviceName
|
||
}
|
||
|
||
var body: some View
|
||
{
|
||
Group
|
||
{
|
||
if engine.devices.isEmpty
|
||
{
|
||
ContentUnavailableView(t("ios.devices.empty"), systemImage: "laptopcomputer.slash")
|
||
}
|
||
else
|
||
{
|
||
List
|
||
{
|
||
ForEach(allDevices)
|
||
{ dev in
|
||
deviceRow(dev)
|
||
.contentShape(Rectangle())
|
||
// 长条行统一操作逻辑:滑动 + 长按都能移除(点按不再删除——破坏性操作不应误触)。
|
||
.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)
|
||
{
|
||
Button(role: .destructive) { revokeTarget = dev; showRevoke = true }
|
||
label: { Label(t("ios.devices.remove"), systemImage: "trash") }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.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)
|
||
{
|
||
if let target = revokeTarget { engine.revokeDevice(target.deviceID) }
|
||
}
|
||
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
|
||
{
|
||
Label
|
||
{
|
||
VStack(alignment: .leading, spacing: 2)
|
||
{
|
||
HStack(spacing: 6)
|
||
{
|
||
Text(dev.name)
|
||
if isSelf(dev)
|
||
{
|
||
Text(t("ios.devices.thisDevice"))
|
||
.font(.caption2)
|
||
.foregroundStyle(Color.cdropAccent)
|
||
}
|
||
}
|
||
Text(dev.online
|
||
? t("settings.online")
|
||
: t("settings.lastSeen", [ "time": relativeTime(dev.lastSeen) ]))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
icon:
|
||
{
|
||
Image(systemName: deviceSymbol(dev.type))
|
||
.foregroundStyle(dev.online ? Color.green : Color.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct SettingsView: View
|
||
{
|
||
@Environment(EngineController.self) private var engine
|
||
@Environment(AuthManager.self) private var auth
|
||
// 设备名编辑态:持久在 UserDefaults,提交时落库。当前会话名烤在令牌里,改名下次登录生效。
|
||
@State private var deviceNameDraft = DeviceNameStore.value
|
||
|
||
var body: some View
|
||
{
|
||
List
|
||
{
|
||
if let user = auth.session?.user
|
||
{
|
||
Section
|
||
{
|
||
LabeledContent(t("ios.settings.user"), value: user.name)
|
||
// 即时改名(设备名与令牌解耦):提交即经引擎 PATCH /api/devices/{device_id} 改名,
|
||
// 不换 token、不重连、不产生重复项,全端即时看到新名(见 EngineController.renameSelf)。
|
||
TextField(t("ios.settings.deviceName"), text: $deviceNameDraft)
|
||
.submitLabel(.done)
|
||
.onSubmit { commitRename() }
|
||
Button(role: .destructive) { logout() }
|
||
label:
|
||
{
|
||
Text(t("ios.settings.logout"))
|
||
}
|
||
}
|
||
header:
|
||
{
|
||
Text(t("ios.settings.account"))
|
||
}
|
||
footer:
|
||
{
|
||
Text(t("ios.settings.deviceNameNote"))
|
||
}
|
||
}
|
||
Section(t("ios.settings.engine"))
|
||
{
|
||
LabeledContent(t("ios.settings.status"), value: engine.status)
|
||
LabeledContent(t("ios.settings.device"),
|
||
value: engine.deviceName.isEmpty ? "—" : engine.deviceName)
|
||
LabeledContent(t("ios.settings.signaling"), value: signalingState)
|
||
LabeledContent(t("ios.settings.presenceEvents"), value: "\(engine.presenceCount)")
|
||
LabeledContent(t("ios.settings.deviceCount"), value: "\(engine.devices.count)")
|
||
// 日志独立界面(#3):进入查看最近 N 条引擎日志;副标题预览最新一条。
|
||
NavigationLink
|
||
{
|
||
LogView()
|
||
}
|
||
label:
|
||
{
|
||
VStack(alignment: .leading, spacing: 2)
|
||
{
|
||
Text(t("ios.settings.viewLogs"))
|
||
if !engine.lastEngineLog.isEmpty
|
||
{
|
||
Text(engine.lastEngineLog)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Section
|
||
{
|
||
Button { engine.uploadClipboard() }
|
||
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") }
|
||
}
|
||
header:
|
||
{
|
||
Text(t("ios.settings.clipboard"))
|
||
}
|
||
footer:
|
||
{
|
||
Text(t("ios.settings.clipboardNote"))
|
||
}
|
||
}
|
||
.navigationTitle(t("ios.tab.settings"))
|
||
}
|
||
|
||
// 信令连接态:已连接 / 重连中 / 未连接,三态便于区分「在努力连但连不上」与「根本没连」。
|
||
private var signalingState: String
|
||
{
|
||
if engine.hubConnected { return t("app.connected") }
|
||
if engine.hubReconnecting { return t("ios.settings.reconnecting") }
|
||
return t("ios.engine.disconnected")
|
||
}
|
||
|
||
// 即时改名:提交时把草稿交引擎改名(PATCH /api/devices/{device_id})。空 / 未变则忽略。
|
||
// 成功后引擎经 renamed 事件落本地名 + Keychain 会话名 + 刷新 engine.deviceName。
|
||
private func commitRename()
|
||
{
|
||
let trimmed = deviceNameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty, trimmed != (auth.session?.deviceName ?? "") else { return }
|
||
engine.renameSelf(to: trimmed)
|
||
}
|
||
|
||
// 登出:先复位引擎(断 SSE + 丢 WebView),再清会话回登录页(清 Keychain 由 auth 负责)。
|
||
private func logout()
|
||
{
|
||
engine.reset()
|
||
auth.logout()
|
||
}
|
||
}
|
||
|
||
// 收到的文件(TODO #2 数据侧延伸 + 解 send 测试无源文件):列 Documents 沙盒里已落盘的
|
||
// 接收文件,可 QuickLook 预览 / 系统分享 / 转发给在线设备 / 删除。这批文件也经 Info.plist
|
||
// 的 UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace 在 Files app「我的 iPhone」
|
||
// 下可见。转发用已有发送链路(engine.sendFile),故收到的文件可直接再发。
|
||
struct ReceivedFilesView: View
|
||
{
|
||
@Environment(EngineController.self) private var engine
|
||
@State private var files: [URL] = []
|
||
@State private var previewItem: PreviewItem?
|
||
@State private var pickedURL: URL?
|
||
@State private var showDevicePicker = false
|
||
@State private var showNoDevices = false
|
||
|
||
var body: some View
|
||
{
|
||
Group
|
||
{
|
||
if files.isEmpty
|
||
{
|
||
ContentUnavailableView(t("ios.files.empty"), systemImage: "tray")
|
||
}
|
||
else
|
||
{
|
||
List
|
||
{
|
||
ForEach(files, id: \.self)
|
||
{ url in
|
||
Button { previewItem = PreviewItem(url: url) }
|
||
label: { fileRow(url) }
|
||
.buttonStyle(.plain)
|
||
.swipeActions(edge: .leading)
|
||
{
|
||
Button { startSend(url) }
|
||
label: { Label(t("ios.transfer.outgoing"), systemImage: "paperplane") }
|
||
.tint(.cdropAccent)
|
||
}
|
||
.contextMenu
|
||
{
|
||
ShareLink(item: url)
|
||
{ Label(t("ios.files.share"), systemImage: "square.and.arrow.up") }
|
||
Button { startSend(url) }
|
||
label: { Label(t("ios.transfer.outgoing"), systemImage: "paperplane") }
|
||
Button(role: .destructive) { delete(url) }
|
||
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
|
||
}
|
||
}
|
||
.onDelete { offsets in deleteAt(offsets) } // 尾滑删除 + EditButton 批量删
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(t("ios.files.title"))
|
||
.toolbar
|
||
{
|
||
if !files.isEmpty
|
||
{
|
||
ToolbarItem(placement: .topBarTrailing) { EditButton() }
|
||
}
|
||
}
|
||
.sheet(item: $previewItem) { item in FilePreviewSheet(item: item) }
|
||
.onAppear { reload() }
|
||
.onChange(of: engine.history.count) { reload() }
|
||
.confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
|
||
{
|
||
ForEach(engine.sendableDevices())
|
||
{ dev in
|
||
Button(dev.name) { send(to: dev.name) }
|
||
}
|
||
Button(t("common.cancel"), role: .cancel) { }
|
||
}
|
||
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
|
||
}
|
||
|
||
private func fileRow(_ url: URL) -> some View
|
||
{
|
||
HStack(spacing: 10)
|
||
{
|
||
Image(systemName: "doc")
|
||
.foregroundStyle(.secondary)
|
||
VStack(alignment: .leading, spacing: 2)
|
||
{
|
||
Text(url.lastPathComponent)
|
||
.lineLimit(1)
|
||
Text(fileSizeString(url))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func reload() { files = engine.receivedFiles() }
|
||
|
||
private func delete(_ url: URL)
|
||
{
|
||
engine.deleteReceivedFile(url)
|
||
reload()
|
||
}
|
||
|
||
private func deleteAt(_ offsets: IndexSet)
|
||
{
|
||
for i in offsets { engine.deleteReceivedFile(files[i]) }
|
||
reload()
|
||
}
|
||
|
||
private func startSend(_ url: URL)
|
||
{
|
||
pickedURL = url
|
||
if engine.sendableDevices().isEmpty { showNoDevices = true }
|
||
else { showDevicePicker = true }
|
||
}
|
||
|
||
private func send(to device: String)
|
||
{
|
||
guard let url = pickedURL else { return }
|
||
engine.sendFile(to: device, fileURL: url)
|
||
pickedURL = nil
|
||
}
|
||
}
|
||
|
||
// MARK: - 展示助手
|
||
|
||
// 是否仍在进行中(非终态):决定显示进度条 / 阶段还是终态标签。
|
||
private func isActive(_ item: TransferItem) -> Bool
|
||
{
|
||
return item.state != "DONE" && item.state != "FAILED" && item.state != "CANCELLED"
|
||
}
|
||
|
||
private func progressOf(_ item: TransferItem) -> Double
|
||
{
|
||
guard item.fileSize > 0, let sent = item.bytesTransferred else { return 0 }
|
||
return min(1.0, Double(sent) / Double(item.fileSize))
|
||
}
|
||
|
||
private func formatBytes(_ bytes: Int) -> String
|
||
{
|
||
let f = ByteCountFormatter()
|
||
f.countStyle = .file
|
||
return f.string(fromByteCount: Int64(bytes))
|
||
}
|
||
|
||
private func fileSizeString(_ url: URL) -> String
|
||
{
|
||
let size = (try? url.resourceValues(forKeys: [ .fileSizeKey ]))?.fileSize ?? 0
|
||
return formatBytes(size)
|
||
}
|
||
|
||
// lastSeen 是 epoch 秒(后端 devices.last_seen=now.Unix(),web formatRelative 也按秒);
|
||
// 转相对时间填进共享 settings.lastSeen 文案的 {{time}}。
|
||
private func relativeTime(_ epochSec: Double) -> String
|
||
{
|
||
let date = Date(timeIntervalSince1970: epochSec)
|
||
let f = RelativeDateTimeFormatter()
|
||
f.unitsStyle = .short
|
||
return f.localizedString(for: date, relativeTo: Date())
|
||
}
|
||
|
||
// 设备类型 → SF Symbol(对齐 web mapKind 的语义)。
|
||
private func deviceSymbol(_ type: String) -> String
|
||
{
|
||
let s = type.lowercased()
|
||
if s.contains("mac") || s == "darwin" { return "laptopcomputer" }
|
||
if s.contains("win") { return "pc" }
|
||
if s.contains("linux") { return "terminal" }
|
||
if s.contains("ipad") { return "ipad" }
|
||
if s.contains("ios") || s.contains("iphone") { return "iphone" }
|
||
if s.contains("android") { return "candybarphone" }
|
||
if s == "shortcut" { return "bolt" }
|
||
if s == "browser" { return "globe" }
|
||
return "desktopcomputer"
|
||
}
|
||
|
||
// 品牌主色 Color.cdropAccent 已移至 Shared/Theme.swift(主 app 与控件 / 小组件扩展共用单一真源)。
|