feat(share): rework server share as QR sheet

- Replace toolbar QR popover with a window sheet per sketch: top hint,
  280pt white-backed QR, copy-image / save-image / copy-ss:// actions
  and a Done button; all buttons share a 260pt min label width
- Freeze share payload and suggested file name at presentation time;
  transient "copied" feedback on copy actions
- Add ImageClipboard seam (PNG normative type + TIFF in AppKit adapter)
  and QrImageSaver seam (NSSavePanel PNG, sanitized suggested name)
- Register new typed failures in the presentation whitelist; extend
  platform-effects architecture guard and seam tests
- Record spec in docs/design/server-share-sheet.md; GLOSSARY server
  share now covers saved QR image files
This commit is contained in:
Qiu Yuzhou
2026-10-05 13:54:59 +08:00
parent 7c44cb4542
commit fd6fa65a8d
11 changed files with 428 additions and 60 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
用户请求生成的可分享文件快照,包含一个配置组及其全部后代的连接资料和所需凭据。它可以包含不具备激活条件的服务器配置。
**服务器分享(Server share)**:
通过二维码或剪贴板传递单个服务器配置的 `ss://` URI,供其他设备使用。
通过二维码(屏幕显示、复制图片或保存图片文件)或剪贴板传递单个服务器配置的 `ss://` URI,供其他设备使用。
_避免用词_:导出(导出指配置组或诊断报告等文件快照)。
**服务器导入(Server import)**:
@@ -67,6 +67,10 @@ extension AppPresentation {
return systemProxy(SystemProxyOperationFailure(error))
case let error as TextClipboardFailure:
return textClipboard(error)
case let error as ImageClipboardFailure:
return imageClipboard(error)
case let error as QrImageSaveFailure:
return qrImageSave(error)
case let error as DiagnosticReportFailure:
return diagnosticReport(error)
case let error as DiagnosticReportExportFailure:
@@ -327,6 +331,18 @@ extension AppPresentation {
}
}
private static func imageClipboard(_ error: ImageClipboardFailure) -> String {
switch error {
case .writeFailed: return "剪贴板写入失败,请重试"
}
}
private static func qrImageSave(_ error: QrImageSaveFailure) -> String {
switch error {
case .writeFailed: return "二维码图片写入失败,请选择其他位置或重试"
}
}
private static func diagnosticReport(_ error: DiagnosticReportFailure) -> String {
switch error {
case .encodingFailed: return "诊断报告无法安全构造,请重试"
+10 -2
View File
@@ -131,6 +131,7 @@ private struct AppComposition {
let silentLaunch: SilentLaunchController
let expansion: CatalogExpansionState
let textClipboard: any TextClipboard
let imageClipboard: any ImageClipboard
let rulesWorkflow: RulesWorkflow
let workspaceContent: MainWindowView
@@ -200,8 +201,10 @@ private struct AppComposition {
silentLaunch: silentLaunch,
expansion: expansion,
clipboard: textClipboard,
imageClipboard: dependencies.imageClipboard,
diagnosticReportExporter: dependencies.diagnosticReportExporter,
configurationGroupFileExporter: dependencies.configurationGroupFileExporter)
configurationGroupFileExporter: dependencies.configurationGroupFileExporter,
qrImageSaver: dependencies.qrImageSaver)
return AppComposition(
controller: controller,
catalogWorkflow: catalogWorkflow,
@@ -213,6 +216,7 @@ private struct AppComposition {
silentLaunch: silentLaunch,
expansion: expansion,
textClipboard: textClipboard,
imageClipboard: dependencies.imageClipboard,
rulesWorkflow: rulesWorkflow,
workspaceContent: workspaceContent)
}
@@ -280,8 +284,10 @@ private struct ApplicationDependencies {
let loginService: LaunchAtLoginControlling
let silentLaunchStore: SilentLaunchStore
let textClipboard: any TextClipboard
let imageClipboard: any ImageClipboard
let diagnosticReportExporter: any DiagnosticReportExporter
let configurationGroupFileExporter: any ConfigurationGroupFileExporter
let qrImageSaver: any QrImageSaver
static func make() -> ApplicationDependencies {
// ADR 0017 初版文件形态的迁移(发布链从未包含,仅存量开发机):文件不存在
@@ -308,8 +314,10 @@ private struct ApplicationDependencies {
loginService: SMAppLaunchAtLoginService(),
silentLaunchStore: SilentLaunchStore(),
textClipboard: AppKitTextClipboard(),
imageClipboard: AppKitImageClipboard(),
diagnosticReportExporter: AppKitDiagnosticReportExporter(),
configurationGroupFileExporter: AppKitConfigurationGroupFileExporter())
configurationGroupFileExporter: AppKitConfigurationGroupFileExporter(),
qrImageSaver: AppKitQrImageSaver())
}
}
+5 -1
View File
@@ -34,8 +34,10 @@ struct MainWindowView: View {
/// 侧栏使用(见 CatalogExpansionState);首页独立保存浏览状态。
let expansion: CatalogExpansionState
let clipboard: any TextClipboard
let imageClipboard: any ImageClipboard
let diagnosticReportExporter: any DiagnosticReportExporter
let configurationGroupFileExporter: any ConfigurationGroupFileExporter
let qrImageSaver: any QrImageSaver
@StateObject private var homeServerList = HomeServerListState()
/// 激活反馈共享状态:首页目标树、侧栏右键与分组详情三个激活入口共用。
@@ -126,7 +128,9 @@ struct MainWindowView: View {
selection: $selection,
expansion: expansion,
clipboard: clipboard,
configurationGroupFileExporter: configurationGroupFileExporter)
imageClipboard: imageClipboard,
configurationGroupFileExporter: configurationGroupFileExporter,
qrImageSaver: qrImageSaver)
case .subscriptions:
SubscriptionsView(
workflow: workflow,
@@ -0,0 +1,22 @@
import AppKit
/// 生产 AppKit image clipboard adapter,与 AppKitTextClipboard 同为 pasteboard
/// replacement 语义的集中点。PNG 为规范类型,另附 TIFF 兼容类型(部分接收方
/// 如聊天应用只认 TIFF);TIFF 附加失败不影响 PNG 已写入这一结果。
@MainActor
final class AppKitImageClipboard: ImageClipboard {
private let pasteboard: NSPasteboard
init(pasteboard: NSPasteboard = .general) {
self.pasteboard = pasteboard
}
func write(_ pngData: Data) throws {
guard pasteboard.clearContents() != 0, pasteboard.setData(pngData, forType: .png) else {
throw ImageClipboardFailure.writeFailed
}
if let image = NSImage(data: pngData), let tiff = image.tiffRepresentation {
_ = pasteboard.setData(tiff, forType: .tiff)
}
}
}
@@ -0,0 +1,31 @@
import Foundation
/// 用户主动触发的图片剪贴板 seam:以 PNG 数据为规范输入写入剪贴板。生产
/// adapter 负责平台类型映射(PNG 之外附加哪些兼容类型),协议不感知;读取
/// 侧暂无消费者,协议不设 read。分享策略与二维码生成不属于此协议。
@MainActor
protocol ImageClipboard {
func write(_ pngData: Data) throws
}
enum ImageClipboardFailure: Equatable, Error {
case writeFailed
}
/// 测试与 Preview 使用的内存实现:不触碰用户的真实 pasteboard。
@MainActor
final class InMemoryImageClipboard: ImageClipboard {
private(set) var pngData: Data?
var writeFailure: ImageClipboardFailure?
init(writeFailure: ImageClipboardFailure? = nil) {
self.writeFailure = writeFailure
}
func write(_ pngData: Data) throws {
if let writeFailure {
throw writeFailure
}
self.pngData = pngData
}
}
@@ -0,0 +1,76 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
/// 二维码图片保存的用户选择与文件写入边界;PNG 数据与建议文件名由 UI 准备。
/// 命名用「保存/ saver」而不用导出(导出术语保留给配置组与诊断报告文件快照,
/// GLOSSARY.md)。
@MainActor
protocol QrImageSaver {
func save(_ draft: QrImageSaveDraft) -> QrImageSaveResult
}
struct QrImageSaveDraft: Equatable {
/// 二维码 PNG 数据;字段名与 ConfigurationGroupExportDraft.data 同法,
/// 使文件写入调用命中架构守卫的 `.data.write(to:` 聚焦 token。
let data: Data
let suggestedFileName: String
/// 保存面板默认文件名:显示名中的路径非法字符(/ 与 :)替换为空格并去首尾
/// 空白,清洗后为空回退固定名,保证面板总有可编辑的初始名。
static func suggestedFileName(from displayName: String) -> String {
let cleaned =
displayName
.replacingOccurrences(of: "/", with: " ")
.replacingOccurrences(of: ":", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let base = cleaned.isEmpty ? "ss-qrcode" : cleaned
return "\(base).png"
}
}
enum QrImageSaveFailure: Equatable, Error {
case writeFailed
}
enum QrImageSaveResult: Equatable {
case cancelled
case saved(URL)
case failed(QrImageSaveFailure)
}
/// 生产文件边界:PNG 保存面板与原子文件写入集中在此 AppKit adapter。
@MainActor
final class AppKitQrImageSaver: QrImageSaver {
func save(_ draft: QrImageSaveDraft) -> QrImageSaveResult {
let panel = NSSavePanel()
panel.allowedContentTypes = [.png]
panel.nameFieldStringValue = draft.suggestedFileName
guard panel.runModal() == .OK, let url = panel.url else {
return .cancelled
}
do {
try draft.data.write(to: url, options: .atomic)
return .saved(url)
} catch {
return .failed(.writeFailed)
}
}
}
/// Tests and previews receive the draft without opening a panel or writing a file.
@MainActor
final class InMemoryQrImageSaver: QrImageSaver {
var result: QrImageSaveResult
private(set) var drafts: [QrImageSaveDraft] = []
init(result: QrImageSaveResult = .cancelled) {
self.result = result
}
func save(_ draft: QrImageSaveDraft) -> QrImageSaveResult {
drafts.append(draft)
return result
}
}
+159 -36
View File
@@ -1,29 +1,16 @@
import SwiftUI
/// 服务器分区的分享与二维码呈现(issue #16/D10,地图 #52 票 #55):
/// 复制 ss:// 与二维码弹窗;分享是显式命令,二维码在后台线程生成。
/// 2026-10-04 自详情区底部「复制 ss:// 链接」「二维码…」两按钮上收为分区
/// 工具栏「分享」按钮,作用于当前选中的服务器叶子:分组与空选择被 seam
/// 点名拒绝,凭据无法解析同样不可用,两者共用 nil 载荷这一 disabled 判据。
/// 服务器分区的分享与二维码呈现(issue #16/D10,地图 #52 票 #55;2026-10-05
/// 按草图自工具栏 popover 升级为 sheet,docs/design/server-share-sheet.md):
/// 大幅二维码加复制图片、保存图片、复制 ss:// 三条通路。分享是显式命令,
/// 作用于打开时选中的服务器叶子,载荷与建议文件名随 ShareContext 冻结,
/// sheet 生命周期内不随选择漂移。
extension ServersView {
var qrPopover: some View {
VStack(spacing: 12) {
if let qrImage {
Image(nsImage: qrImage)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: 220, height: 220)
} else {
ProgressView()
.frame(width: 220, height: 220)
}
Text("用其他设备的客户端扫描此二维码")
.font(.footnote)
.foregroundStyle(.secondary)
Button("复制 ss:// 链接") { copySsUri() }
}
.padding(20)
/// 打开 sheet 即冻结的分享载荷与保存面板建议名。
struct ShareContext: Identifiable {
let payload: String
let suggestedFileName: String
var id: String { payload }
}
/// 分享载荷:仅服务器叶子可产生(分组与未知节点被 seam 点名拒绝);
@@ -33,25 +20,161 @@ extension ServersView {
return try? workflow.shareURI(for: selection)
}
func copySsUri() {
guard let payload = sharePayload() else { return }
func presentShare() {
guard let selection, let payload = try? workflow.shareURI(for: selection) else { return }
shareContext = ShareContext(
payload: payload,
suggestedFileName: QrImageSaveDraft.suggestedFileName(
from: workflow.displayName(for: selection)))
}
}
/// 分享 sheet 本体:草图的顶部说明加大幅二维码,三个动作按钮纵向铺满,
/// 「完成」收尾;两个复制动作给瞬时「已复制」反馈,保存由系统面板反馈。
struct ShareServerSheet: View {
let payload: String
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>?
enum CopyFeedback: Equatable {
case none
case image
case link
}
/// 四枚按钮统一最小宽:所有标签(含「已复制」反馈态)都远小于该值,保证
/// 视觉等宽,且按草图略窄于二维码块(minWidth 施于标签内侧,bordered 样式
/// 随标签边界绘制)。
private static let buttonMinWidth: CGFloat = 260
var body: some View {
VStack(spacing: 16) {
Text("用其他设备的客户端扫描此二维码")
.font(.headline)
qrDisplay
VStack(spacing: 8) {
shareButton("复制二维码图片", feedback: .image, disabled: png == nil) {
copyImage()
}
shareButton("保存二维码图片", feedback: nil, disabled: png == nil) {
saveImage()
}
// ss:// 链接不依赖二维码生成,sheet 打开即可复制。
shareButton("复制 ss:// 链接", feedback: .link, disabled: false) {
copySsUri()
}
}
Button {
dismiss()
} label: {
Text("完成")
.frame(minWidth: Self.buttonMinWidth)
}
.keyboardShortcut(.cancelAction)
.buttonStyle(.bordered)
}
.padding(24)
.frame(width: 340)
.task { await generate() }
.onDisappear { feedbackResetTask?.cancel() }
}
private var qrDisplay: some View {
Group {
if let png, let image = NSImage(data: png) {
Image(nsImage: image)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: 280, height: 280)
} else {
ProgressView()
.frame(width: 280, height: 280)
}
}
// 白底衬底保证深色外观下的扫码对比度;阴影让浅色外观下也有边界。
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.white)
.shadow(color: .black.opacity(0.15), radius: 2, y: 1)
)
}
private func shareButton(
_ title: 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)
}
}
.frame(minWidth: Self.buttonMinWidth)
}
.buttonStyle(.bordered)
.disabled(disabled)
}
/// 二维码在后台线程生成(重置即先呈进度,不闪现上一台服务器的码)。
private func generate() async {
let payload = payload
let data = await Task.detached(priority: .userInitiated) {
try? QrCodeCodec.generatePNG(for: payload)
}.value
png = data
}
private func copyImage() {
guard let png else { return }
do {
try clipboard.write(payload)
try imageClipboard.write(png)
flash(.image)
} catch {
errors.present(error)
}
}
func generateQR() {
guard let payload = sharePayload() else { return }
// 重置旧图:换选中项重开弹窗时先呈进度,不闪现上一台服务器的二维码。
qrImage = nil
showQR = true
Task.detached(priority: .userInitiated) {
let image = (try? QrCodeCodec.generatePNG(for: payload)).flatMap { NSImage(data: $0) }
await MainActor.run {
qrImage = image
}
private func saveImage() {
guard let png else { return }
switch saver.save(QrImageSaveDraft(data: png, suggestedFileName: suggestedFileName)) {
case .cancelled, .saved:
break // 保存与取消都由系统面板给出反馈
case .failed(let failure):
errors.present(failure)
}
}
private func copySsUri() {
do {
try textClipboard.write(payload)
flash(.link)
} catch {
errors.present(error)
}
}
/// 复制成功反馈:标签短暂换成「已复制」,重复点击重置计时。
private func flash(_ feedback: CopyFeedback) {
copyFeedback = feedback
feedbackResetTask?.cancel()
feedbackResetTask = Task {
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled else { return }
copyFeedback = .none
}
}
}
+22 -14
View File
@@ -19,7 +19,9 @@ struct ServersView: View {
/// 切换存续;OutlineGroup 的内建展开态会随分区切换丢失,侧栏因此自管折叠。
@ObservedObject var expansion: CatalogExpansionState
let clipboard: any TextClipboard
let imageClipboard: any ImageClipboard
let configurationGroupFileExporter: any ConfigurationGroupFileExporter
let qrImageSaver: any QrImageSaver
/// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。
/// 非 private:ServersView+Share.swift 扩展经它呈现分享失败。
@@ -36,10 +38,10 @@ struct ServersView: View {
@State private var moveTarget: NodeID?
@State private var rootDropHovering = false
@FocusState private var treeFocused: Bool
// 分享/二维码状态:由 ServersView+Share.swift 扩展驱动,弹窗锚在工具栏
// 分享按钮上(原详情区底部分享区已于 2026-10-04 上收至此)。
@State var showQR = false
@State var qrImage: NSImage?
// 分享 sheet 状态:由 ServersView+Share.swift 扩展驱动;2026-10-05 自工具
// 栏 popover 升级为 sheet(docs/design/server-share-sheet.md),打开时冻结
// 载荷与建议文件名。
@State var shareContext: ShareContext?
var body: some View {
// 主窗口外壳(票 #53)已提供分栏框架;本分区用扁平双栏承载目录与详情
@@ -92,6 +94,19 @@ 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
}
}
private var serverSidebar: some View {
@@ -285,23 +300,16 @@ extension ServersView {
.help("删除")
.disabled(!canDeleteSelection)
}
// 分享(分享/导出术语见 GLOSSARY.md):作用于选中的服务器叶子,弹窗内
// 提供二维码与复制 ss:// 链接两条通路;无有效载荷即不可用。
// 分享(分享/导出术语见 GLOSSARY.md):作用于选中的服务器叶子,sheet 内
// 提供二维码、复制/保存图片与复制 ss:// 链接;无有效载荷即不可用。
ToolbarItem(placement: .primaryAction) {
Button {
generateQR()
presentShare()
} label: {
Label("分享", systemImage: "square.and.arrow.up")
}
.help("分享")
.disabled(sharePayload() == nil)
.popover(isPresented: $showQR) {
qrPopover
}
// 弹窗开着时切换选中项:立即收起,避免内容滞留为旧服务器。
.onChange(of: selection) {
showQR = false
}
}
}
@@ -27,6 +27,53 @@ final class TextClipboardTests: XCTestCase {
}
}
@MainActor
final class ImageClipboardTests: XCTestCase {
func testWrittenPNGIsStored() throws {
let clipboard = InMemoryImageClipboard()
try clipboard.write(Data([0x89, 0x50]))
XCTAssertEqual(clipboard.pngData, Data([0x89, 0x50]))
}
func testWriteFailureIsTypedAndDoesNotExposePlatformDetails() {
let clipboard = InMemoryImageClipboard(writeFailure: .writeFailed)
XCTAssertThrowsError(try clipboard.write(Data())) { error in
XCTAssertEqual(error as? ImageClipboardFailure, .writeFailed)
}
}
}
@MainActor
final class QrImageSaverTests: XCTestCase {
func testDraftIsPassedThroughToSaver() {
let saver = InMemoryQrImageSaver()
let draft = QrImageSaveDraft(data: Data([0x01]), suggestedFileName: "机房.png")
let outcome = saver.save(draft)
XCTAssertEqual(outcome, .cancelled)
XCTAssertEqual(saver.drafts, [draft])
}
func testSuggestedFileNameReplacesPathReservedCharacters() {
XCTAssertEqual(
QrImageSaveDraft.suggestedFileName(from: "机房/一号:备用"),
"机房 一号 备用.png")
}
func testSuggestedFileNameFallsBackWhenCleaningLeavesNothing() {
XCTAssertEqual(QrImageSaveDraft.suggestedFileName(from: " /: "), "ss-qrcode.png")
XCTAssertEqual(QrImageSaveDraft.suggestedFileName(from: ""), "ss-qrcode.png")
}
func testSuggestedFileNameKeepsCleanNameAndAppendsPNGExtension() {
XCTAssertEqual(QrImageSaveDraft.suggestedFileName(from: " 家里 "), "家里.png")
}
}
@MainActor
final class DiagnosticReportExportActionTests: DiagnosticsWorkflowTestCase {
func testCancelledExportDoesNotRecordCompletion() {
@@ -99,20 +146,27 @@ final class PlatformEffectsArchitectureTests: XCTestCase {
let pasteboardUsers = try Self.files(containing: "NSPasteboard", in: sourceFiles)
XCTAssertEqual(
pasteboardUsers.map(\.lastPathComponent), ["AppKitTextClipboard.swift"],
"纯文本 pasteboard 访问必须集中在 AppKitTextClipboard adapter")
pasteboardUsers.map(\.lastPathComponent).sorted(),
["AppKitImageClipboard.swift", "AppKitTextClipboard.swift"],
"pasteboard 访问必须集中在 clipboard adapter(文本与图片各一)")
let savePanelUsers = try Self.files(containing: "NSSavePanel", in: sourceFiles)
XCTAssertEqual(
Set(savePanelUsers.map(\.lastPathComponent)),
Set(["AppKitDiagnosticReportExporter.swift", "ConfigurationGroupFileExporter.swift"]),
"保存面板访问必须集中在对应的 AppKit exporter adapter")
Set([
"AppKitDiagnosticReportExporter.swift", "ConfigurationGroupFileExporter.swift",
"QrImageSaver.swift",
]),
"保存面板访问必须集中在对应的 AppKit exporter/saver adapter")
let reportWriterUsers = try Self.files(containing: ".data.write(to:", in: sourceFiles)
XCTAssertEqual(
Set(reportWriterUsers.map(\.lastPathComponent)),
Set(["AppKitDiagnosticReportExporter.swift", "ConfigurationGroupFileExporter.swift"]),
"文件写入必须集中在对应的 AppKit exporter adapter")
Set([
"AppKitDiagnosticReportExporter.swift", "ConfigurationGroupFileExporter.swift",
"QrImageSaver.swift",
]),
"文件写入必须集中在对应的 AppKit exporter/saver adapter")
}
private static let testTargetRoot = URL(fileURLWithPath: #filePath)
+26
View File
@@ -0,0 +1,26 @@
# 服务器分享 sheet(2026-10-05)
本文件记录本轮已确认规格(按用户提供的草图方向实现),现状以代码为准。
## 需求
服务器分区的「分享」由工具栏 popover(220×220 二维码 + 复制 ss:// 链接)升级为窗口 sheet,按草图布局:顶部说明文字、大幅二维码、三个纵向按钮(复制二维码图片 / 保存二维码图片 / 复制 ss:// 链接)。入口与可用性判据不变:仅服务器分区工具栏「分享」按钮,作用于选中的服务器叶子,无有效载荷即禁用。
## 已确认决策
- 呈现:popover 整体替换为 `.sheet(item:)`;打开时冻结分享载荷与建议文件名(`ShareContext`),sheet 生命周期内不随选择漂移;选中项变化时立即收起。Esc 与「完成」按钮均可关闭。
- 二维码:显示 280×280,白底圆角衬底保证深色外观下的扫码对比度;后台线程生成,就绪前呈进度,图片动作禁用。生成参数不变(`QrCodeCodec.generatePNG`,CIQRCodeGenerator,纠错级 M,scale 8)。
- 复制二维码图片:同一 pasteboard 写 PNG(规范类型)+ TIFF(兼容类型,附加失败不影响 PNG 已写入);不附带 ss:// 文本。
- 保存二维码图片:只提供 PNG,走 NSSavePanel;默认文件名取服务器显示名,路径非法字符(/ 与 :)替换为空格并去首尾空白,清洗后为空回退 `ss-qrcode`,统一追加 `.png`。
- 复制反馈:两个复制动作成功后按钮文案短暂变为「✓ 已复制」约 2 秒,重复点击重置计时;保存由系统面板反馈,不做额外提示。
- 按钮:四枚按钮(三个动作 + 完成)标签统一最小宽 260pt(略窄于二维码块,最长标签与反馈态均小于该值),居中排布,宽度不随文案切换跳变。
- 术语:保存二维码图片文件属于「分享」,不叫导出(GLOSSARY.md 的导出保留给配置组/诊断报告文件快照);新 seam 命名用 saver。GLOSSARY「服务器分享」条目已更新。
## 设计
- 新增平台 seam(与既有 TextClipboard / ConfigurationGroupFileExporter 同法,协议 + InMemory + AppKit adapter,`NSPasteboard`/`NSSavePanel`/文件写入仍集中在聚焦 adapter,守卫见 `PlatformEffectsArchitectureTests`):
- `ImageClipboard` / `InMemoryImageClipboard` / `AppKitImageClipboard`:图片剪贴板写入。
- `QrImageSaver` / `InMemoryQrImageSaver` / `AppKitQrImageSaver`(含 `QrImageSaveDraft.suggestedFileName(from:)` 命名清洗):二维码图片保存面板与原子写入。
- `ShareServerSheet`(`ServersView+Share.swift`)自持二维码与反馈状态;错误沿用共享 `ErrorAlertPresenter` 呈现边缘(与 NewGroupSheet 同法)。
- 注入链:组合根 `ApplicationDependencies` → `MainWindowView` → `ServersView` → sheet。
- typed failure 在 `AppPresentation.message(for:)` 白名单登记:图片剪贴板写入失败、二维码图片写入失败。