127070e226
设备名与令牌解耦(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)
814 lines
30 KiB
Swift
814 lines
30 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)
|
||
{
|
||
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_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
|
||
{
|
||
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_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"
|
||
}
|
||
|
||
// 品牌主色(强约束③,与 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)
|
||
})
|
||
}
|