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 // 初始标签可经 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.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) .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 pickedURL: URL? @State private var showDevicePicker = false @State private var showNoDevices = 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) } } } .padding() } } .navigationTitle(t("ios.tab.transfer")) .overlay(alignment: .bottomTrailing) { Button { showImporter = true } 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 } } } .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 } } // 传输卡片:内容层,用语义材质背景而非玻璃(玻璃专属功能 / 导航层)。状态 / 阶段 / 速率 // 文案全部复用共享 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 { 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) } } private func isSelf(_ dev: DeviceItem) -> Bool { 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.name) } } 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) TextField(t("ios.settings.deviceName"), text: $deviceNameDraft) .submitLabel(.done) .onChange(of: deviceNameDraft) { DeviceNameStore.value = deviceNameDraft } 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") } // 登出:先复位引擎(断 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) }) }