Files
Commilitia-Drop/ios/CDrop/MacApp/MacRootView.swift
T
admin f273f2466e Mac Quick Send:全局热键 + 菜单栏(Phase 4c)
- MacQuickSend(@Observable 单例):激活 app + NSOpenPanel 多选文件 → 置 pending;MacRootView 观察 pending → 切到「传输」区 + 弹 MacComposeSheet 选目标设备发送(复用现有发送流程与 engine.sendFile)
- MacHotkey:Carbon RegisterEventHotKey 全局热键 ⌃⌥⌘S(无须辅助功能权限)触发 Quick Send。事件处理器须 C 函数指针,用不捕获闭包(只调静态 MacQuickSend.trigger)+ dispatch 主队列再弹模态(避免 Carbon 回调内跑模态)。登录(MacRootView onAppear)注册、登出(onDisappear)注销
- 菜单栏加「快速发送文件…」(登录态)
- 桌面 Wails 实际未实现全局热键(仅曾计划),此为原生 Mac 增益。键位暂固定 ⌃⌥⌘S,后续可做成设置项
- 验证:just mac-build(CommilitiaDropMac.app)BUILD SUCCEEDED;iOS 无关(仅改 macOS-only 文件),不受影响
2026-07-08 00:15:10 +08:00

329 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#if os(macOS)
import AppKit
import SwiftUI
// NavigationSplitView / / / / + NavigationStack
// iOS WebView iOS RootView / /
// Phase 3b iOS / presence / / 3a
// i18nt() Phase 3b
struct MacRootView: View
{
@Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth
@AppStorage("macClipboardAutoSync") private var autoSync = true
@State private var quickSend = MacQuickSend.shared
@State private var selection: MacSection = .transfers
var body: some View
{
NavigationSplitView
{
List(selection: $selection)
{
brandHeader
.listRowInsets(EdgeInsets(top: 10, leading: 8, bottom: 14, trailing: 8))
.listRowSeparator(.hidden)
.selectionDisabled()
Section
{
ForEach(MacSection.sidebar)
{ section in
Label(section.title, systemImage: section.icon)
.badge(badge(for: section))
.tag(section)
}
}
}
.navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280)
.navigationTitle("Commilitia Drop")
}
detail:
{
NavigationStack
{
detail
}
}
// WebView boot iOS RootView
.background
{
EngineWebView(controller: engine, auth: auth)
.frame(width: 0, height: 0)
.opacity(0)
.allowsHitTesting(false)
}
// / / / iOS RootView
.overlay(alignment: .top)
{
NoticeOverlay()
}
//
// / Quick Send S
.onAppear { syncClipboardMonitor(); MacHotkey.shared.register() }
.onChange(of: autoSync) { syncClipboardMonitor() }
.onDisappear { MacClipboardMonitor.shared.stop(); MacHotkey.shared.unregister() }
// Quick Send /
.onChange(of: quickSend.pending)
{ _, urls in
if !urls.isEmpty { selection = .transfers }
}
.sheet(isPresented: Binding(get: { !quickSend.pending.isEmpty },
set: { if !$0 { quickSend.pending = [] } }))
{
MacComposeSheet(urls: quickSend.pending,
onCancel: { quickSend.pending = [] },
onSend:
{ device in
for url in quickSend.pending { engine.sendFile(to: device, fileURL: url) }
quickSend.pending = []
})
.environment(engine)
}
}
private func syncClipboardMonitor()
{
if autoSync { MacClipboardMonitor.shared.start { engine.uploadClipboardText($0) } }
else { MacClipboardMonitor.shared.stop() }
}
@ViewBuilder
private var detail: some View
{
switch selection
{
case .transfers:
MacTransfersView()
case .files:
MacFilesView()
case .messages:
MacMessagesView()
case .devices:
MacDevicesView()
case .settings:
MacSettingsView()
}
}
private var brandHeader: some View
{
HStack(spacing: 10)
{
Image(systemName: "paperplane.fill")
.font(.title2)
.foregroundStyle(.tint)
Text("Commilitia Drop")
.font(.title3.weight(.semibold))
.lineLimit(1)
}
}
// / iOS RootView badge
private func badge(for section: MacSection) -> Int
{
switch section
{
case .messages: return engine.unreadMessages
case .transfers: return engine.unreadTransfers
default: return 0
}
}
}
// iOS / / / /
enum MacSection: Hashable, Identifiable, CaseIterable
{
case transfers, files, messages, devices, settings
var id: Self { self }
var title: String
{
switch self
{
case .transfers: return "传输"
case .files: return "文件"
case .messages: return "消息"
case .devices: return "设备"
case .settings: return "设置"
}
}
var icon: String
{
switch self
{
case .transfers: return "arrow.up.arrow.down"
case .files: return "folder"
case .messages: return "bubble.left.and.bubble.right"
case .devices: return "laptopcomputer.and.iphone"
case .settings: return "gearshape"
}
}
static var sidebar: [MacSection] { MacSection.allCases }
}
// 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 others.isEmpty
{
ContentUnavailableView("暂无其他设备", systemImage: "laptopcomputer.and.iphone",
description: Text("同账户的其他设备将显示在此。"))
}
else
{
List(others)
{ dev in
HStack(spacing: 10)
{
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()
}
.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) 的登录,该设备需重新登录。")
}
}
}
// + + + + +
struct MacSettingsView: View
{
@Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth
@AppStorage("macClipboardAutoSync") private var autoSync = true
@AppStorage("macDownloadDir") private var downloadDirPath = ""
@State private var nameDraft = DeviceNameStore.value
private var signalingLabel: String
{
if engine.hubConnected { return "已连接" }
if engine.hubReconnecting { return "重连中…" }
return "未连接"
}
var body: some View
{
Form
{
Section("账号")
{
LabeledContent("用户", value: auth.session?.user.name ?? "")
HStack
{
TextField("本机设备名", text: $nameDraft)
.onSubmit { commitRename() }
Button("保存") { commitRename() }
.disabled(nameDraft.trimmingCharacters(in: .whitespaces).isEmpty)
}
Button("登出", role: .destructive)
{
engine.reset()
auth.logout()
}
}
Section("云剪贴板")
{
Toggle("自动同步本机复制(上行)", isOn: $autoSync)
Button("上传本机剪贴板") { engine.uploadClipboard() }
Button("拉取云剪贴板到本机") { engine.pullClipboard() }
}
Section("接收")
{
LabeledContent("保存到", value: downloadDirDisplay)
Button("选择目录…") { pickDownloadDir() }
}
Section("引擎")
{
LabeledContent("状态", value: engine.status)
LabeledContent("信令", value: signalingLabel)
LabeledContent("在线设备", value: "\(engine.presenceCount)")
}
Section("引擎日志")
{
if engine.logs.isEmpty
{
Text("暂无日志").foregroundStyle(.secondary)
}
else
{
ForEach(engine.logs.prefix(30))
{ entry in
Text(entry.message)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
}
}
}
.formStyle(.grouped)
.navigationTitle("设置")
}
private func commitRename()
{
let name = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
engine.renameSelf(to: name)
}
private var downloadDirDisplay: String
{
downloadDirPath.isEmpty ? "下载(默认)" : (downloadDirPath as NSString).abbreviatingWithTildeInPath
}
private func pickDownloadDir()
{
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "选择"
if panel.runModal() == .OK, let url = panel.url
{
downloadDirPath = url.path
}
}
}
#endif