5bee5b8942
在共享引擎公开 API 之上写 Mac 原生视图(不复用 iOS TabView 视图,改按 Mac 惯用交互),接入 MacRootView 五区取代占位。引擎零改动——feature 方法皆 internal,Mac 视图同 target 可直调。 - 传输 MacTransfersView:NSOpenPanel 多选发送 + 拖放发送(.dropDestination,Mac 独有无 iOS 前例)+ 进行中列表(进度条 / 速度 / 直连·中继 / 右键取消·切中继)+ 历史(右键打开·在 Finder 显示·删记录)+ 清空历史;MacComposeSheet(待发文件列表 + 目标设备 radioGroup 选择) - 文件 MacFilesView:engine.receivedFiles() 轮询(onAppear + history.count 变即刷)+ 双击 / 右键打开(NSWorkspace.open)+ 在 Finder 显示(activateFileViewerSelecting)+ 转发(confirmationDialog 选设备 → sendFile)+ 删除 - 消息 MacMessagesView:HSplitView 双栏(会话列表 + 线程),选中即 setActiveConversation + markConversationRead、离开置 nil,撰写栏 canMessage 门控,气泡 in/out 对齐 + 未读红点 - 设备 MacDevicesView 增强:macDeviceSymbol 类型图标 + 排除本机(selfDeviceID)+ 右键移除设备(confirmationDialog + revokeDevice) - 设置 MacSettingsView 增强:设备名可改(TextField + renameSelf)+ 云剪贴板上传 / 拉取 + 引擎诊断(status / 信令 hubConnected·hubReconnecting / 在线设备数)+ 引擎日志 - NoticeOverlay 挂 MacRootView 窗口顶部(吊销 / 改名 / 剪贴板 / 登录审批反馈自动横幅);Sources/NotificationBanner.swift 纳入 Mac target - deviceSymbol / fileByteSize 因所在 iOS 源文件(RootView / SendComposeSheet)未纳入 Mac target,按同语义在 MacFeatureViews 另置一份 - 验证:just mac-build(CommilitiaDropMac.app)+ iOS 模拟器(CommilitiaDrop.app)均 BUILD SUCCEEDED
534 lines
18 KiB
Swift
534 lines
18 KiB
Swift
#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
|