#if os(macOS) import AppKit import SwiftUI // Mac 原生五区中的「传输 / 文件 / 消息」三区(设备 / 设置在 MacRootView)。均建于共享引擎 // (EngineController)公开 API 之上——不复用 iOS 的 TabView 视图,改按 Mac 惯用交互(NSOpenPanel / // 拖放 / 右键菜单 / 在 Finder 显示 / HSplitView 双栏)。文案暂硬编中文,Mac 侧 i18n 统一待后续。 // 设备类型 → SF Symbol(对应 iOS deviceSymbol;该 iOS 自由函数在 RootView.swift 未纳入 Mac target, // 故此处按同一映射另置一份)。 func macDeviceSymbol(_ type: String) -> String { switch type.lowercased() { case let t where t.contains("mac"), let t where t.contains("darwin"): return "laptopcomputer" case let t where t.contains("win"): return "pc" case let t where t.contains("linux"): return "terminal" case let t where t.contains("ipad"): return "ipad" case let t where t.contains("iphone"), let t where t.contains("ios"): return "iphone" case let t where t.contains("android"): return "candybarphone" case let t where t.contains("shortcut"): return "bolt" case let t where t.contains("browser"): return "globe" default: return "desktopcomputer" } } private func macFormatBytes(_ n: Int) -> String { return ByteCountFormatter.string(fromByteCount: Int64(max(n, 0)), countStyle: .file) } // NSOpenPanel 选文件(多选、仅文件)。runModal 同步阻塞,供按钮动作直用。 private func pickFilesViaOpenPanel() -> [URL] { let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = true panel.prompt = "发送" return panel.runModal() == .OK ? panel.urls : [] } // MARK: - 传输 struct MacTransfersView: View { @Environment(EngineController.self) private var engine @State private var composeURLs: [URL] = [] @State private var showCompose = false @State private var showNoDevices = false var body: some View { List { if !engine.transfers.isEmpty { Section("进行中") { ForEach(engine.transfers) { MacTransferRow(item: $0) } } } if !engine.history.isEmpty { Section("历史") { ForEach(engine.history) { MacHistoryRow(item: $0) } } } if engine.transfers.isEmpty, engine.history.isEmpty { ContentUnavailableView("暂无传输", systemImage: "arrow.up.arrow.down", description: Text("点右上「发送文件」或把文件拖到此处,选目标设备发送。")) } } .navigationTitle("传输") .toolbar { ToolbarItem { Button { let urls = pickFilesViaOpenPanel() if !urls.isEmpty { present(urls) } } label: { Label("发送文件", systemImage: "plus") } } if !engine.history.isEmpty { ToolbarItem { Button("清空历史") { engine.clearHistory() } } } } // 拖放发送(Mac 独有,无 iOS 前例):拖入本地文件 → 同选设备发送流程。 .dropDestination(for: URL.self) { urls, _ in let files = urls.filter { !$0.hasDirectoryPath } guard !files.isEmpty else { return false } present(files) return true } .sheet(isPresented: $showCompose) { MacComposeSheet(urls: composeURLs, onCancel: { showCompose = false }, onSend: { device in for url in composeURLs { engine.sendFile(to: device, fileURL: url) } composeURLs = [] showCompose = false }) } .alert("暂无可达设备", isPresented: $showNoDevices) { Button("好", role: .cancel) {} } message: { Text("请确保有其他在线设备(或可唤醒的 iOS 设备)。") } } private func present(_ urls: [URL]) { guard !engine.sendableDevices().isEmpty else { showNoDevices = true; return } composeURLs = urls showCompose = true } } // 进行中传输行:方向 + 文件名 + 进度 + 速度 + 取消 / 切中继(右键菜单)。 struct MacTransferRow: View { @Environment(EngineController.self) private var engine let item: TransferItem private var fraction: Double { Double(item.bytesTransferred ?? 0) / Double(max(item.fileSize, 1)) } var body: some View { HStack(spacing: 10) { Image(systemName: item.direction == "incoming" ? "arrow.down.circle" : "arrow.up.circle") .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 3) { Text(item.fileName).lineLimit(1) ProgressView(value: min(max(fraction, 0), 1)) HStack(spacing: 8) { Text(item.peerName) if let bps = item.bytesPerSec, bps > 0 { Text("· \(macFormatBytes(Int(bps)))/s") } if let mode = item.mode { Text("· \(mode == "relay" ? "中继" : "直连")") } Spacer() Text(macFormatBytes(item.bytesTransferred ?? 0) + " / " + macFormatBytes(item.fileSize)) } .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 2) .contextMenu { Button("取消", role: .destructive) { engine.cancelTransfer(item.sessionId) } if item.mode != "relay" { Button("切换到中继") { engine.switchToRelay(item.sessionId) } } } } } // 历史行:方向 + 文件名 + 状态;打开(若本地存在)/ 删除(右键)。 struct MacHistoryRow: View { @Environment(EngineController.self) private var engine let item: TransferItem private var localURL: URL? { item.direction == "incoming" ? engine.receivedFile(matching: item.fileName) : nil } var body: some View { HStack(spacing: 10) { Image(systemName: item.direction == "incoming" ? "arrow.down" : "arrow.up") .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { Text(item.fileName).lineLimit(1) Text("\(item.peerName) · \(macFormatBytes(item.fileSize)) · \(macStateLabel(item.state))") .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding(.vertical, 2) .contextMenu { if let url = localURL { Button("打开") { NSWorkspace.shared.open(url) } Button("在 Finder 中显示") { NSWorkspace.shared.activateFileViewerSelecting([url]) } } Button("删除记录", role: .destructive) { engine.deleteTransferRecord(item.sessionId) } } } } private func macStateLabel(_ state: String) -> String { switch state { case "completed": return "已完成" case "failed": return "失败" case "cancelled", "canceled": return "已取消" default: return state } } // Mac 原生发送表单:待发文件列表 + 目标设备选择(对应 iOS SendComposeSheet)。 struct MacComposeSheet: View { @Environment(EngineController.self) private var engine let urls: [URL] let onCancel: () -> Void let onSend: (String) -> Void @State private var selected = "" var body: some View { VStack(spacing: 0) { Form { Section("待发送(\(urls.count))") { ForEach(urls, id: \.self) { url in HStack { Image(systemName: "doc").foregroundStyle(.secondary) Text(url.lastPathComponent).lineLimit(1) Spacer() Text(macFormatBytes(fileByteSizeMac(url))) .font(.caption) .foregroundStyle(.secondary) } } } Section("目标设备") { let devices = engine.sendableDevices() if devices.isEmpty { Text("暂无可达设备").foregroundStyle(.secondary) } else { Picker("发送到", selection: $selected) { ForEach(devices) { dev in Text(engine.sendLabel(dev)).tag(dev.name) } } .pickerStyle(.radioGroup) } } } .formStyle(.grouped) Divider() HStack { Button("取消", role: .cancel) { onCancel() } Spacer() Button("发送") { onSend(selected) } .keyboardShortcut(.defaultAction) .disabled(selected.isEmpty || urls.isEmpty) } .padding(12) } .frame(width: 440, height: 380) .onAppear { if selected.isEmpty { selected = engine.sendableDevices().first?.name ?? "" } } } } // 文件字节大小(对应 iOS fileByteSize;安全作用域 URL 须临时声明访问)。 private func fileByteSizeMac(_ url: URL) -> Int { let access = url.startAccessingSecurityScopedResource() defer { if access { url.stopAccessingSecurityScopedResource() } } return (try? url.resourceValues(forKeys: [ .fileSizeKey ]))?.fileSize ?? 0 } // MARK: - 收到的文件 struct MacFilesView: View { @Environment(EngineController.self) private var engine @State private var files: [URL] = [] @State private var forwarding: URL? var body: some View { Group { if files.isEmpty { ContentUnavailableView("暂无收到的文件", systemImage: "folder", description: Text("收到的文件会落到「文稿」目录,并在此列出。")) } else { List(files, id: \.self) { url in HStack(spacing: 10) { Image(systemName: "doc") .foregroundStyle(.tint) Text(url.lastPathComponent).lineLimit(1) Spacer() Text(macFormatBytes(fileByteSizeMac(url))) .font(.caption) .foregroundStyle(.secondary) } .contentShape(Rectangle()) .onTapGesture(count: 2) { NSWorkspace.shared.open(url) } .contextMenu { Button("打开") { NSWorkspace.shared.open(url) } Button("在 Finder 中显示") { NSWorkspace.shared.activateFileViewerSelecting([url]) } Button("转发…") { forwarding = url } Divider() Button("删除", role: .destructive) { engine.deleteReceivedFile(url) reload() } } } } } .navigationTitle("文件") .onAppear { reload() } .onChange(of: engine.history.count) { reload() } .confirmationDialog("转发到…", isPresented: Binding( get: { forwarding != nil }, set: { if !$0 { forwarding = nil } })) { if let url = forwarding { ForEach(engine.sendableDevices()) { dev in Button(engine.sendLabel(dev)) { engine.sendFile(to: dev.name, fileURL: url) } } } Button("取消", role: .cancel) {} } } private func reload() { files = engine.receivedFiles() } } // MARK: - 消息 struct MacMessagesView: View { @Environment(EngineController.self) private var engine @State private var selectedPeer: String? var body: some View { HSplitView { // 会话列表 List(selection: $selectedPeer) { ForEach(engine.conversations()) { convo in HStack(spacing: 8) { Image(systemName: macDeviceSymbol(convo.deviceType)) .foregroundStyle(convo.online ? Color.green : Color.secondary) VStack(alignment: .leading, spacing: 2) { Text(convo.peerName).lineLimit(1) Text(convo.lastText).font(.caption).foregroundStyle(.secondary).lineLimit(1) } Spacer() let n = engine.unread(for: convo.peerName) if n > 0 { Text("\(n)") .font(.caption2) .padding(.horizontal, 6).padding(.vertical, 2) .background(Color.red, in: Capsule()) .foregroundStyle(.white) } } .tag(convo.peerName) } } .frame(minWidth: 220, idealWidth: 260, maxWidth: 340) // 会话线程 Group { if let peer = selectedPeer { MacThreadView(peer: peer) .id(peer) } else { ContentUnavailableView("选择一个会话", systemImage: "bubble.left.and.bubble.right") } } .frame(minWidth: 360, maxWidth: .infinity, maxHeight: .infinity) } .navigationTitle("消息") .onChange(of: selectedPeer) { _, peer in engine.setActiveConversation(peer) if let peer { engine.markConversationRead(peer) } } .onDisappear { engine.setActiveConversation(nil) } } } // 单会话线程:气泡列表 + 撰写栏(canMessage 门控)。 struct MacThreadView: View { @Environment(EngineController.self) private var engine let peer: String @State private var draft = "" var body: some View { VStack(spacing: 0) { ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(engine.thread(with: peer)) { msg in MacMessageBubble(msg: msg) } } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) } Divider() if engine.canMessage(peer) { HStack(spacing: 8) { TextField("发消息…", text: $draft, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1 ... 4) .onSubmit(send) Button("发送", action: send) .keyboardShortcut(.return, modifiers: []) .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding(10) } else { Text("对方当前不可达,仅可查看历史。") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding(10) } } } private func send() { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } engine.sendMessage(to: peer, text: text) draft = "" } } struct MacMessageBubble: View { let msg: MessageItem private var outgoing: Bool { msg.direction == "outgoing" } var body: some View { HStack { if outgoing { Spacer(minLength: 60) } Text(msg.text) .textSelection(.enabled) .padding(.horizontal, 12).padding(.vertical, 8) .background(outgoing ? Color.accentColor.opacity(0.85) : Color.secondary.opacity(0.18), in: RoundedRectangle(cornerRadius: 12)) .foregroundStyle(outgoing ? Color.white : Color.primary) if !outgoing { Spacer(minLength: 60) } } .frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading) } } #endif