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(engine.sendLabel(dev)) { 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(engine.sendLabel(dev)) { 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(engine.sendLabel(dev)) { 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 与控件 / 小组件扩展共用单一真源)。