Files
Commilitia-Drop/ios/CDrop/Sources/RootView.swift
T
admin c1dda6759c iOS 与传输修复:取消即时落终态 + 行操作统一 + 记录按用户分桶 + 发送端进度/完成对齐接收端
- 取消传输无效:web cancelTransfer 拆除本端 p2p/relay 后立即 completeTransfer(sessionId, "CANCELLED") 落终态,不再仅依赖服务端 transfer:state CANCELLED 回显——回显延迟、或因设备名变更致路由不中而未送达时,旧实现使记录永停“进行中”,即用户所见“取消无效”。completeTransfer 幂等,随后回显安全重入。
- 进行中/已完成传输行操作统一:进行中行新增滑动“取消”+ 长按菜单“取消/切中继”,与历史行滑动+长按“删除”对称。取消即时无确认、随 transferDone 移入 history,析构滑动动画与现实一致不抖。详情页取消/切中继入口保留。
- 传输历史/消息持久化:记录按登录用户 userId 分桶持久化(EngineController recordKey/activateRecords/persist*,含旧无桶文件一次性回退)。reset() 改为只清内存、不再 RecordsStore.clear 磁盘——会话失效(authExpired/被吊销)属凭证层事件,不应连用户数据一并销毁;同一账户重登经 activateRecords 即恢复,换账号各读各桶天然防跨账号泄露。CDropApp 在 onAppear 与 onChange(session.user.id) 激活记录。
- 发送端进度/完成对齐接收端:接收端经数据通道节流(200ms)+finalize 即时回传已收字节(ack 帧);发送端进度改用 ackedBytes(单调取大),取代仅反映本地 SCTP 缓冲抽干的 senderDeliveredBytes——避免 Windows→iOS 等慢接收端组合下发送端进度虚高领先、完成早十数秒(4MB)。streamFile 抽干 buffer 后再等 ack 追平总量才宣告完成;ackedBytes==0(旧版本接收端)走旧“抽干即完成”行为、30s 超时兜底、接收端权威 /done 经 SSE 独立完成。
2026-06-27 20:15:08 +08:00

903 lines
35 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import PhotosUI
import SwiftUI
import UIKit
import UniformTypeIdentifiers
// + iOS 26 SDK WebView
//
// ios/PLAN.md §8.1 i18nt()web catalog /
// presence / EngineControllerarch 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:) sessionIdString 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()
}
}
// + /
//
// / transferDone
// history /
@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
.swipeActions(edge: .trailing)
{
Button(role: .destructive) { engine.cancelTransfer(item.sessionId) }
label: { Label(t("ios.transfer.cancel"), systemImage: "xmark.circle") }
}
.contextMenu
{
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") }
}
}
}
}
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_idselfDeviceID
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_seennow.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 /