Files
Commilitia-Drop/ios/CDrop/Sources/RootView.swift
T
admin 127070e226 设备名与令牌解耦 + iOS Broker 应用内登录 / 消息 / 图库 / 记录持久化 + 三端 bug 修复
设备名与令牌解耦(presence 按 device_id 键控):
- hub 主键改稳定 device_id(presence / online / kick 用之),peer 路由仍按设备名经 hub 内 clientFor 解析——不动传输核心;Client 加 Name + 连接键,PresenceDevice 加 device_id,sse 传 claims.DeviceID,publishPresence 按 device_id 判在线、name 取自 devices 表
- 改名走已有 PATCH /api/devices/{device_id}(不重铸会话、不换 token、不中断 SSE)+ hub.Rename 更新活跃连接名 + 重广播 presence,全端即时无重复项;sessions 列表 name 用 devices 表覆盖 broker Label;web 改名从重铸迁到 PATCH,iOS 设置页即时改名;DeviceItem / DeviceInfo 按 device_id 键控判本机
- 移除「需移除两次」:publishPresence 不再把「有 device_id 但无 devices 行」的 live 连接当在线(吊销后用未过期 token 重连的僵尸),只显示 code-less 连接;合法重登新建行故正常
- revoke 对无 broker 会话的本地行也清(清掉合成测试 / 幽灵设备);跨端吊销 / 断连一律按 device_id Kick

iOS Broker 应用内登录(免扫码):
- BrokerLogin.swift:ASWebAuthenticationSession 跑 broker /device/authorize + /device/token 的 PKCE 设备授权流(对齐桌面 loopback,自定义 scheme cdrop://auth-callback 回调)→ /api/auth/device-session 代铸出带真名 + 稳定 device_id 的设备会话;DeviceIDStore 跨登录复用 device_id 杜绝重登重复
- LoginView 主登录改 broker 账号登录、扫码降级为可展开备选

iOS 功能补全:
- 设备间消息(引擎 main.ts 单条增量过桥 + onNativeEvent sendMessage;EngineController 消息状态 + 新 MessagesView 会话 UI;后端 /api/message 已就绪)
- 图库发送(PhotosPicker 文件 / 图库菜单 → stagePhotoData → 复用 cdrop-file 发送链路;NSPhotoLibraryUsageDescription)
- 传输历史 / 消息记录持久化(RecordsStore 落 Application Support JSON,跨重启存活)+ 长按删除 + 清空(二次确认)
- 进行中传输主动取消 / 立即切中继(详情页两按钮,复用引擎桥命令)
- 身份愈合(引擎 /api/me 取真实显示名经 identityUpdated 回报 Keychain,修扫码登录「用户显示 UUID」)
- 触发本地网络权限(NWBrowser 浏览 _cdrop._tcp)使 WKWebView WebRTC 取 host 候选

iOS 发送 / 启动修复:
- 发送在读文件即失败:引擎在 https 源下 fetch("cdrop-file://") 跨源 + Range 头触发 CORS 预检被拦 → 改走原生桥 bridgeFileSource / readFileSlice(512 KiB 分块 base64),绕开 CORS、整文件仍不整体进内存
- 冷启重开「missing injected session」:EngineWebView.makeUIView 在 boot 注入前确定性接好 auth,消除注入竞态

桌面 bug 修复:
- 登出静默失败:window.confirm 在 Wails WebView 失效(返回 falsy 致动作被静默跳过)→ 跨平台 DOM 确认弹窗(utils/confirm 的 Promise 式 confirmDialog + ui/ConfirmHost 的 Mantine Modal)取代之;登出改 await 先吊销 broker 会话再清本地(桌面 clearDesktopSession 会丢 Go 侧 refresh 的竞态)
- 扫 iOS 登录二维码被拒「不是本站的码」:wails:// 源永不等 https 站点源 → 桌面放宽 parseLinkApprovalUrl 的 origin 等值校验(仍留 /link 路径 + r/c;批准 r/c 一律发往本端后端、外站码只 404,无开放重定向)
- macOS 本地网络权限触发(localnetwork_darwin NWBrowser + Info.plist 键);相机修复(NSCameraUsageDescription)

web / 跨端:
- presence 透传 device_id + 离线检测按 device_id;会话列表随 presence 防抖重拉(已登出设备自动从列表消失,不再需手动刷新);移除设备 / 清剪贴板 / 撤销令牌 / 登出均改 DOM 确认弹窗

分发 / 签名:
- 真机签名从手动 scaffold 改自动 provisioning(删 Signing.xcconfig,project.yml 去 profile specifier,just ios-device 用 -allowProvisioningUpdates + ASC 团队 key);App Group 改名 group.net.commilitia.Commilitia-Drop(含 entitlements)
- hub 测试加 device_id 键控 + 改名解耦回归(-race)
2026-06-27 03:50:04 +08:00

814 lines
30 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)
{
Tab(t("ios.tab.transfer"), systemImage: "arrow.up.arrow.down", value: "transfer")
{
NavigationStack
{
TransferListView()
}
}
Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices")
{
NavigationStack
{
DeviceListView()
}
}
Tab(t("ios.tab.messages"), systemImage: "message", value: "messages")
{
NavigationStack
{
MessagesView()
}
}
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
{
NavigationStack
{
ReceivedFilesView()
}
}
Tab(t("ios.tab.settings"), systemImage: "gearshape", value: "settings")
{
NavigationStack
{
SettingsView()
}
}
}
.tabBarMinimizeBehavior(.onScrollDown)
.tint(.cdropAccent)
.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
// 线 EngineController.sendableDevices
private var sendableDevices: [DeviceItem]
{
engine.sendableDevices()
}
var body: some View
{
ScrollView
{
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()
}
}
.navigationTitle(t("ios.tab.transfer"))
.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) { }
}
private func sectionHeader(_ title: String) -> some View
{
HStack
{
Text(title)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
}
}
private func transferLink(_ item: TransferItem) -> some View
{
NavigationLink
{
TransferDetailView(sessionId: item.sessionId)
}
label:
{
TransferCardView(item: item)
}
.buttonStyle(.plain)
}
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
//
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
{
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)
{
Button(role: .destructive) { revokeTarget = dev; showRevoke = true }
label: { Label(t("ios.devices.remove"), systemImage: "trash") }
}
}
}
}
}
}
.navigationTitle(t("ios.tab.devices"))
.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) { }
}
}
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() }
if !engine.deviceActionStatus.isEmpty
{
Text(engine.deviceActionStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
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)")
if !engine.lastEngineLog.isEmpty
{
VStack(alignment: .leading, spacing: 2)
{
Text(t("ios.settings.lastLog"))
.font(.caption)
.foregroundStyle(.secondary)
Text(engine.lastEngineLog)
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
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") }
if !engine.clipboardStatus.isEmpty
{
Text(engine.clipboardStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
}
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_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"
}
// 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)
})
}