mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-26 11:43:48 +00:00
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:
+1
-1
@@ -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 "诊断报告无法安全构造,请重试"
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:)` 白名单登记:图片剪贴板写入失败、二维码图片写入失败。
|
||||
Reference in New Issue
Block a user