Mac 原生五区视图(Phase 3b)
在共享引擎公开 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
This commit is contained in:
@@ -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
|
||||||
@@ -49,6 +49,11 @@ struct MacRootView: View
|
|||||||
.opacity(0)
|
.opacity(0)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
// 瞬时通知横幅(吊销 / 改名 / 剪贴板 / 登录审批反馈)覆盖窗口顶部。同 iOS RootView。
|
||||||
|
.overlay(alignment: .top)
|
||||||
|
{
|
||||||
|
NoticeOverlay()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -57,11 +62,11 @@ struct MacRootView: View
|
|||||||
switch selection
|
switch selection
|
||||||
{
|
{
|
||||||
case .transfers:
|
case .transfers:
|
||||||
MacPlaceholder(section: .transfers)
|
MacTransfersView()
|
||||||
case .files:
|
case .files:
|
||||||
MacPlaceholder(section: .files)
|
MacFilesView()
|
||||||
case .messages:
|
case .messages:
|
||||||
MacPlaceholder(section: .messages)
|
MacMessagesView()
|
||||||
case .devices:
|
case .devices:
|
||||||
MacDevicesView()
|
MacDevicesView()
|
||||||
case .settings:
|
case .settings:
|
||||||
@@ -128,66 +133,79 @@ enum MacSection: Hashable, Identifiable, CaseIterable
|
|||||||
static var sidebar: [MacSection] { MacSection.allCases }
|
static var sidebar: [MacSection] { MacSection.allCases }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 阶段占位详情(传输 / 文件 / 消息,随 Phase 3b 替换为复用 iOS 视图)。
|
// 设备:引擎经桥推来的真实 presence(图标随类型 + 在线态 + 移除设备)。排除本机。
|
||||||
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。
|
|
||||||
struct MacDevicesView: View
|
struct MacDevicesView: View
|
||||||
{
|
{
|
||||||
@Environment(EngineController.self) private var engine
|
@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
|
var body: some View
|
||||||
{
|
{
|
||||||
Group
|
Group
|
||||||
{
|
{
|
||||||
if engine.devices.isEmpty
|
if others.isEmpty
|
||||||
{
|
{
|
||||||
ContentUnavailableView("暂无其他设备", systemImage: "laptopcomputer.and.iphone",
|
ContentUnavailableView("暂无其他设备", systemImage: "laptopcomputer.and.iphone",
|
||||||
description: Text("同账户的其他在线设备将显示在此。"))
|
description: Text("同账户的其他设备将显示在此。"))
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
List(engine.devices)
|
List(others)
|
||||||
{ dev in
|
{ dev in
|
||||||
HStack(spacing: 10)
|
HStack(spacing: 10)
|
||||||
{
|
{
|
||||||
Circle()
|
Image(systemName: macDeviceSymbol(dev.type))
|
||||||
.fill(dev.online ? Color.green : Color.secondary)
|
.foregroundStyle(dev.online ? Color.green : Color.secondary)
|
||||||
.frame(width: 9, height: 9)
|
VStack(alignment: .leading, spacing: 2)
|
||||||
|
{
|
||||||
Text(dev.name)
|
Text(dev.name)
|
||||||
Spacer()
|
Text(dev.online ? "在线" : "离线")
|
||||||
Text(dev.type)
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.contextMenu
|
||||||
|
{
|
||||||
|
Button("移除设备", role: .destructive) { revokeTarget = dev }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("设备")
|
.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
|
struct MacSettingsView: View
|
||||||
{
|
{
|
||||||
@Environment(EngineController.self) private var engine
|
@Environment(EngineController.self) private var engine
|
||||||
@Environment(AuthManager.self) private var auth
|
@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
|
var body: some View
|
||||||
{
|
{
|
||||||
@@ -196,13 +214,30 @@ struct MacSettingsView: View
|
|||||||
Section("账号")
|
Section("账号")
|
||||||
{
|
{
|
||||||
LabeledContent("用户", value: auth.session?.user.name ?? "—")
|
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)
|
Button("登出", role: .destructive)
|
||||||
{
|
{
|
||||||
engine.reset()
|
engine.reset()
|
||||||
auth.logout()
|
auth.logout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Section("云剪贴板")
|
||||||
|
{
|
||||||
|
Button("上传本机剪贴板") { engine.uploadClipboard() }
|
||||||
|
Button("拉取云剪贴板到本机") { engine.pullClipboard() }
|
||||||
|
}
|
||||||
|
Section("引擎")
|
||||||
|
{
|
||||||
|
LabeledContent("状态", value: engine.status)
|
||||||
|
LabeledContent("信令", value: signalingLabel)
|
||||||
|
LabeledContent("在线设备", value: "\(engine.presenceCount)")
|
||||||
|
}
|
||||||
Section("引擎日志")
|
Section("引擎日志")
|
||||||
{
|
{
|
||||||
if engine.logs.isEmpty
|
if engine.logs.isEmpty
|
||||||
@@ -225,5 +260,12 @@ struct MacSettingsView: View
|
|||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle("设置")
|
.navigationTitle("设置")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func commitRename()
|
||||||
|
{
|
||||||
|
let name = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !name.isEmpty else { return }
|
||||||
|
engine.renameSelf(to: name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ targets:
|
|||||||
- path: Sources/LocalNetworkPermission.swift
|
- path: Sources/LocalNetworkPermission.swift
|
||||||
- path: Sources/DeviceNameStore.swift
|
- path: Sources/DeviceNameStore.swift
|
||||||
- path: Sources/BackgroundTaskManager.swift
|
- path: Sources/BackgroundTaskManager.swift
|
||||||
|
- path: Sources/NotificationBanner.swift
|
||||||
- path: Sources/Resources/i18n
|
- path: Sources/Resources/i18n
|
||||||
dependencies:
|
dependencies:
|
||||||
# 原生数据面 libwebrtc(LibWebRtcEngine)。macOS 切片缺子头,构建经 just mac-prep 补齐
|
# 原生数据面 libwebrtc(LibWebRtcEngine)。macOS 切片缺子头,构建经 just mac-prep 补齐
|
||||||
|
|||||||
Reference in New Issue
Block a user