feat(servers): present sharing in a popover

This commit is contained in:
Qiu Yuzhou
2026-10-05 16:41:18 +08:00
parent 13a548193b
commit bd68e45ea4
4 changed files with 130 additions and 59 deletions
@@ -1,8 +1,26 @@
import Foundation
/// 分享顶部的已保存资料,不包含密码或插件参数。
struct ServerSharePresentation {
let name: String
let address: String
let port: Int
let encryptionMethod: String
let pluginProgram: String?
}
// MARK: - 分享与 URI → 服务器叶子
extension CatalogWorkflow {
func serverSharePresentation(for id: NodeID) -> ServerSharePresentation? {
guard let entry = dependencies.coordinator.committedCatalog.entry(for: id),
case .server(let fields) = entry.kind
else { return nil }
return ServerSharePresentation(
name: entry.displayName, address: fields.address, port: fields.port,
encryptionMethod: fields.encryptionMethod, pluginProgram: fields.pluginProgram)
}
/// 服务器 → ss://(SIP002,story 22):凭据从存储解析;无凭据即点名失败
/// (不分享空档)。仅在用户明确请求分享时调用。
func shareURI(for id: NodeID) throws -> String {
+80 -45
View File
@@ -1,14 +1,11 @@
import SwiftUI
/// 服务器分区的分享与二维码呈现(issue #16/D10,地图 #52 票 #55;2026-10-05
/// 按草图自工具栏 popover 升级为 sheet,docs/design/server-share-sheet.md):
/// 大幅二维码加复制图片、保存图片、复制 ss:// 三条通路。分享是显式命令,
/// 作用于打开时选中的服务器叶子,载荷与建议文件名随 ShareContext 冻结,
/// sheet 生命周期内不随选择漂移。
/// 服务器分享由工具栏按钮旁的 popover 呈现;打开时冻结已保存资料和载荷。
extension ServersView {
/// 打开 sheet 即冻结的分享载荷与保存面板建议名。
/// 打开 popover 即冻结的分享载荷、资料与保存面板建议名。
struct ShareContext: Identifiable {
let payload: String
let presentation: ServerSharePresentation
let suggestedFileName: String
var id: String { payload }
}
@@ -21,25 +18,28 @@ extension ServersView {
}
func presentShare() {
guard let selection, let payload = try? workflow.shareURI(for: selection) else { return }
guard let selection,
let presentation = workflow.serverSharePresentation(for: selection),
let payload = try? workflow.shareURI(for: selection)
else { return }
shareContext = ShareContext(
payload: payload,
presentation: presentation,
suggestedFileName: QrImageSaveDraft.suggestedFileName(
from: workflow.displayName(for: selection)))
from: presentation.name))
}
}
/// 分享 sheet 本体:草图的顶部说明加大幅二维码,三个动作按钮纵向铺满,
/// 「完成」收尾;两个复制动作给瞬时「已复制」反馈,保存由系统面板反馈。
struct ShareServerSheet: View {
/// 系统分享菜单式分区:服务器资料、二维码、带图标的操作行。
struct ShareServerPopover: View {
let payload: String
let presentation: ServerSharePresentation
let suggestedFileName: String
let imageClipboard: any ImageClipboard
let textClipboard: any TextClipboard
let saver: any QrImageSaver
let errors: ErrorAlertPresenter
@Environment(\.dismiss) private var dismiss
@State private var png: Data?
@State private var copyFeedback: CopyFeedback = .none
@State private var feedbackResetTask: Task<Void, Never>?
@@ -50,43 +50,59 @@ struct ShareServerSheet: View {
case link
}
/// 四枚按钮统一最小宽:所有标签(含「已复制」反馈态)都远小于该值,保证
/// 视觉等宽,且按草图略窄于二维码块(minWidth 施于标签内侧,bordered 样式
/// 随标签边界绘制)。
private static let buttonMinWidth: CGFloat = 260
var body: some View {
VStack(spacing: 16) {
Text("用其他设备的客户端扫描此二维码")
.font(.headline)
VStack(spacing: 12) {
serverSummary
.padding(.horizontal, 8)
Divider()
qrDisplay
VStack(spacing: 8) {
shareButton("复制二维码图片", feedback: .image, disabled: png == nil) {
copyImage()
}
shareButton("保存二维码图片", feedback: nil, disabled: png == nil) {
Divider()
VStack(spacing: 2) {
shareButton(
"保存二维码图片", symbol: "square.and.arrow.down", feedback: nil,
disabled: png == nil
) {
saveImage()
}
// ss:// 链接不依赖二维码生成,sheet 打开即可复制。
shareButton("复制 ss:// 链接", feedback: .link, disabled: false) {
shareButton(
"拷贝二维码图片", symbol: "doc.on.doc", feedback: .image,
disabled: png == nil
) {
copyImage()
}
shareButton("拷贝 URI 链接", symbol: "link", feedback: .link, disabled: false) {
copySsUri()
}
}
Button {
dismiss()
} label: {
Text("完成")
.frame(minWidth: Self.buttonMinWidth)
}
.keyboardShortcut(.cancelAction)
.buttonStyle(.bordered)
}
.padding(24)
.frame(width: 340)
.padding(12)
.frame(width: 324)
.task { await generate() }
.onDisappear { feedbackResetTask?.cancel() }
}
private var serverSummary: some View {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: presentation.name)
.font(.headline)
Text(verbatim: endpoint)
.foregroundStyle(.secondary)
Text("加密方式:\(presentation.encryptionMethod)")
if let plugin = presentation.pluginProgram, !plugin.isEmpty {
Text("插件:\(plugin)")
}
}
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var endpoint: String {
let address = presentation.address
let host = address.contains(":") && !address.hasPrefix("[") ? "[\(address)]" : address
return "\(host):\(presentation.port)"
}
private var qrDisplay: some View {
Group {
if let png, let image = NSImage(data: png) {
@@ -111,21 +127,23 @@ struct ShareServerSheet: View {
private func shareButton(
_ title: String,
symbol: String,
feedback: CopyFeedback?,
disabled: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Group {
if let feedback, copyFeedback == feedback {
Label("已复制", systemImage: "checkmark")
} else {
Text(title)
}
HStack(spacing: 12) {
Image(systemName: copyFeedback == feedback ? "checkmark" : symbol)
.frame(width: 20)
Text(copyFeedback == feedback ? "已复制" : title)
Spacer(minLength: 0)
}
.frame(minWidth: Self.buttonMinWidth)
.padding(.horizontal, 8)
.padding(.vertical, 7)
.contentShape(Rectangle())
}
.buttonStyle(.bordered)
.buttonStyle(ShareActionButtonStyle())
.disabled(disabled)
}
@@ -178,3 +196,20 @@ struct ShareServerSheet: View {
}
}
}
/// 保留原生 Button 的键盘与辅助功能,悬停时绘制系统菜单式行底色。
private struct ShareActionButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
@State private var isHovering = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background {
RoundedRectangle(cornerRadius: 6)
.fill(
Color.primary.opacity(isEnabled && (isHovering || configuration.isPressed) ? 0.1 : 0))
}
.opacity(isEnabled ? 1 : 0.4)
.onHover { isHovering = $0 }
}
}
+13 -14
View File
@@ -38,9 +38,7 @@ struct ServersView: View {
@State private var moveTarget: NodeID?
@State private var rootDropHovering = false
@FocusState private var treeFocused: Bool
// 分享 sheet 状态:由 ServersView+Share.swift 扩展驱动;2026-10-05 自工具
// 栏 popover 升级为 sheet(docs/design/server-share-sheet.md),打开时冻结
// 载荷与建议文件名。
// 分享 popover 打开时冻结载荷、顶部资料与建议文件名。
@State var shareContext: ShareContext?
var body: some View {
@@ -94,16 +92,7 @@ struct ServersView: View {
workflow: workflow, errors: errors,
parent: newServerParent, onCreated: selectCreatedNode)
}
.sheet(item: $shareContext) { context in
ShareServerSheet(
payload: context.payload,
suggestedFileName: context.suggestedFileName,
imageClipboard: imageClipboard,
textClipboard: clipboard,
saver: qrImageSaver,
errors: errors)
}
// 分享 sheet 开着时切换选中项:立即收起,避免内容滞留为旧服务器。
// 切换选中项时立即收起分享,避免显示旧服务器。
.onChange(of: selection) {
shareContext = nil
}
@@ -300,7 +289,7 @@ extension ServersView {
.help("删除")
.disabled(!canDeleteSelection)
}
// 分享(分享/导出术语见 GLOSSARY.md):作用于选中的服务器叶子,sheet 内
// 分享(分享/导出术语见 GLOSSARY.md):作用于选中的服务器叶子,popover 内
// 提供二维码、复制/保存图片与复制 ss:// 链接;无有效载荷即不可用。
ToolbarItem(placement: .primaryAction) {
Button {
@@ -310,6 +299,16 @@ extension ServersView {
}
.help("分享")
.disabled(sharePayload() == nil)
.popover(item: $shareContext, arrowEdge: .top) { context in
ShareServerPopover(
payload: context.payload,
presentation: context.presentation,
suggestedFileName: context.suggestedFileName,
imageClipboard: imageClipboard,
textClipboard: clipboard,
saver: qrImageSaver,
errors: errors)
}
}
}
+19
View File
@@ -0,0 +1,19 @@
# 服务器分享 popover(2026-10-05)
本文件记录本轮用户确认的规格。替代前一轮分享 sheet 的呈现设计;当前实现以代码为准。
## 已确认决策
- 点击服务器分区工具栏「分享」,在按钮旁打开原生 popover。移除「完成」按钮,点击外部或按 Esc 关闭;切换选中服务器时立即关闭。
- 顶部依次显示已保存的服务器名称、地址与端口、加密方式、可选插件程序名。无插件时隐藏插件行;名称和地址单行显示、尾部截断。不显示密码或插件参数。
- 打开时冻结顶部资料、分享 URI 和保存面板建议文件名;不读取编辑中的未保存草稿。
- 布局为资料、分隔线、二维码、分隔线、三个操作。二维码保持 280×280 和白底衬底。
- 操作采用系统分享菜单式左对齐整行按钮,悬停显示圆角底色,顺序为「保存二维码图片」(square.and.arrow.down)、「拷贝二维码图片」(doc.on.doc)、「拷贝 URI 链接」(link)。
- 两个拷贝操作成功后保留约 2 秒「已复制」反馈;保存继续使用系统保存面板。图片未就绪时禁用图片操作;链接可直接拷贝。
- 分享入口的可用性、二维码生成、图片剪贴板格式、PNG 保存和失败提示继续沿用现有行为。
## 实现边界
CatalogWorkflow 提供不包含秘密的 ServerSharePresentation 查询。ServersView 在显式分享命令中捕获资料和 URI;ShareServerPopover 自持二维码与反馈状态。平台效果继续经过 ImageClipboard、TextClipboard 和 QrImageSaver。
现有 GLOSSARY「服务器分享」定义适用,无新增领域术语。这是可逆的 UI 呈现调整,不新增 ADR。