diff --git a/ios/CDrop/MacApp/MacFeatureViews.swift b/ios/CDrop/MacApp/MacFeatureViews.swift new file mode 100644 index 0000000..a8051ac --- /dev/null +++ b/ios/CDrop/MacApp/MacFeatureViews.swift @@ -0,0 +1,533 @@ +#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 diff --git a/ios/CDrop/MacApp/MacRootView.swift b/ios/CDrop/MacApp/MacRootView.swift index cec9fd4..324dd4e 100644 --- a/ios/CDrop/MacApp/MacRootView.swift +++ b/ios/CDrop/MacApp/MacRootView.swift @@ -49,6 +49,11 @@ struct MacRootView: View .opacity(0) .allowsHitTesting(false) } + // 瞬时通知横幅(吊销 / 改名 / 剪贴板 / 登录审批反馈)覆盖窗口顶部。同 iOS RootView。 + .overlay(alignment: .top) + { + NoticeOverlay() + } } @ViewBuilder @@ -57,11 +62,11 @@ struct MacRootView: View switch selection { case .transfers: - MacPlaceholder(section: .transfers) + MacTransfersView() case .files: - MacPlaceholder(section: .files) + MacFilesView() case .messages: - MacPlaceholder(section: .messages) + MacMessagesView() case .devices: MacDevicesView() case .settings: @@ -128,66 +133,79 @@ enum MacSection: Hashable, Identifiable, CaseIterable static var sidebar: [MacSection] { MacSection.allCases } } -// 阶段占位详情(传输 / 文件 / 消息,随 Phase 3b 替换为复用 iOS 视图)。 -struct MacPlaceholder: View -{ - let section: MacSection - - var body: some View - { - ContentUnavailableView - { - Label(section.title, systemImage: section.icon) - } - description: - { - Text("即将接入(原生数据面复用 iOS 引擎,桌面功能适配中)。") - } - .navigationTitle(section.title) - } -} - -// 设备:引擎经桥推来的真实 presence(在线绿点 + 名称 + 类型),证明登录后引擎已联通信令 / SSE。 +// 设备:引擎经桥推来的真实 presence(图标随类型 + 在线态 + 移除设备)。排除本机。 struct MacDevicesView: View { @Environment(EngineController.self) private var engine + @State private var revokeTarget: DeviceItem? + + private var others: [DeviceItem] + { + engine.devices.filter { $0.deviceID != engine.selfDeviceID } + } var body: some View { Group { - if engine.devices.isEmpty + if others.isEmpty { ContentUnavailableView("暂无其他设备", systemImage: "laptopcomputer.and.iphone", - description: Text("同账户的其他在线设备将显示在此。")) + description: Text("同账户的其他设备将显示在此。")) } else { - List(engine.devices) + List(others) { dev in HStack(spacing: 10) { - Circle() - .fill(dev.online ? Color.green : Color.secondary) - .frame(width: 9, height: 9) - Text(dev.name) + Image(systemName: macDeviceSymbol(dev.type)) + .foregroundStyle(dev.online ? Color.green : Color.secondary) + VStack(alignment: .leading, spacing: 2) + { + Text(dev.name) + Text(dev.online ? "在线" : "离线") + .font(.caption) + .foregroundStyle(.secondary) + } Spacer() - Text(dev.type) - .font(.caption) - .foregroundStyle(.secondary) + } + .contextMenu + { + Button("移除设备", role: .destructive) { revokeTarget = dev } } } } } .navigationTitle("设备") + .confirmationDialog("移除该设备?", + isPresented: Binding(get: { revokeTarget != nil }, + set: { if !$0 { revokeTarget = nil } }), + presenting: revokeTarget) + { dev in + Button("移除 \(dev.name)", role: .destructive) { engine.revokeDevice(dev.deviceID) } + Button("取消", role: .cancel) {} + } + message: + { dev in + Text("将吊销 \(dev.name) 的登录,该设备需重新登录。") + } } } -// 设置:账号 + 本机设备名 + 登出 + 引擎日志(3a 联通与现场自检)。 +// 设置:账号 + 本机设备名(可改)+ 云剪贴板 + 引擎状态 + 引擎日志 + 登出。 struct MacSettingsView: View { @Environment(EngineController.self) private var engine @Environment(AuthManager.self) private var auth + @State private var nameDraft = DeviceNameStore.value + + private var signalingLabel: String + { + if engine.hubConnected { return "已连接" } + if engine.hubReconnecting { return "重连中…" } + return "未连接" + } var body: some View { @@ -196,13 +214,30 @@ struct MacSettingsView: View Section("账号") { LabeledContent("用户", value: auth.session?.user.name ?? "—") - LabeledContent("本机设备", value: engine.deviceName) + HStack + { + TextField("本机设备名", text: $nameDraft) + .onSubmit { commitRename() } + Button("保存") { commitRename() } + .disabled(nameDraft.trimmingCharacters(in: .whitespaces).isEmpty) + } Button("登出", role: .destructive) { engine.reset() auth.logout() } } + Section("云剪贴板") + { + Button("上传本机剪贴板") { engine.uploadClipboard() } + Button("拉取云剪贴板到本机") { engine.pullClipboard() } + } + Section("引擎") + { + LabeledContent("状态", value: engine.status) + LabeledContent("信令", value: signalingLabel) + LabeledContent("在线设备", value: "\(engine.presenceCount)") + } Section("引擎日志") { if engine.logs.isEmpty @@ -225,5 +260,12 @@ struct MacSettingsView: View .formStyle(.grouped) .navigationTitle("设置") } + + private func commitRename() + { + let name = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + engine.renameSelf(to: name) + } } #endif diff --git a/ios/CDrop/project.yml b/ios/CDrop/project.yml index 2621925..8b3830d 100644 --- a/ios/CDrop/project.yml +++ b/ios/CDrop/project.yml @@ -193,6 +193,7 @@ targets: - path: Sources/LocalNetworkPermission.swift - path: Sources/DeviceNameStore.swift - path: Sources/BackgroundTaskManager.swift + - path: Sources/NotificationBanner.swift - path: Sources/Resources/i18n dependencies: # 原生数据面 libwebrtc(LibWebRtcEngine)。macOS 切片缺子头,构建经 just mac-prep 补齐