Add save file option for profile sharing

Add "Save File" and "Save Content JSON" options to profile share menu,
allowing users to save profiles directly to a chosen location using
fileExporter instead of the system share sheet.
This commit is contained in:
世界
2026-01-01 20:25:28 +08:00
parent 55e01a89b5
commit 313cb5d213
11 changed files with 1674 additions and 26 deletions
@@ -533,8 +533,19 @@ private struct ProfilePickerRow: View {
@State private var showQRCode = false
#if os(macOS)
@State private var shareItemType: ShareItemType?
@State private var exportItemType: ExportItemType?
@State private var profileExportDocument: ProfileExportDocument?
@State private var showProfileExporter = false
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
@State private var showJSONExporter = false
@State private var menuAnchorView: NSView?
#endif
#if os(iOS)
@State private var profileExportDocument: ProfileExportDocument?
@State private var showProfileExporter = false
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
@State private var showJSONExporter = false
#endif
var body: some View {
#if os(tvOS)
@@ -697,6 +708,28 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.fileExporter(
isPresented: $showProfileExporter,
document: profileExportDocument,
contentType: .profile,
defaultFilename: profileExportDocument?.filename
) { result in
profileExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
.fileExporter(
isPresented: $showJSONExporter,
document: profileJSONExportDocument,
contentType: .json,
defaultFilename: profileJSONExportDocument?.filename
) { result in
profileJSONExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
}
private var macOSEditingBody: some View {
@@ -720,6 +753,28 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.fileExporter(
isPresented: $showProfileExporter,
document: profileExportDocument,
contentType: .profile,
defaultFilename: profileExportDocument?.filename
) { result in
profileExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
.fileExporter(
isPresented: $showJSONExporter,
document: profileJSONExportDocument,
contentType: .json,
defaultFilename: profileJSONExportDocument?.filename
) { result in
profileJSONExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
}
#endif
@@ -828,6 +883,11 @@ private struct ProfilePickerRow: View {
self.shareItemType = nil
shareProfile(type: shareItemType)
}
.onChange(of: exportItemType) { exportItemType in
guard let exportItemType else { return }
self.exportItemType = nil
exportProfileMacOS(type: exportItemType)
}
#endif
}
@@ -836,17 +896,53 @@ private struct ProfilePickerRow: View {
private var shareMenu: some View {
Menu {
#if os(macOS)
Button {
exportItemType = .file
} label: {
Label("Save File", systemImage: "square.and.arrow.down")
}
Button {
shareItemType = .file
} label: {
Label("Share File", systemImage: "doc")
}
Button {
exportItemType = .json
} label: {
Label("Save Content JSON", systemImage: "square.and.arrow.down")
}
Button {
shareItemType = .json
} label: {
Label("Share Content JSON File", systemImage: "curlybraces")
}
#else
Button {
exportProfile(type: .file)
} label: {
Label("Save File", systemImage: "square.and.arrow.down")
}
ShareButtonCompat($alert) {
Label("Share File", systemImage: "doc")
} itemURL: {
try profile.origin.toContent().generateShareFile()
}
Button {
exportProfile(type: .json)
} label: {
Label("Save Content JSON", systemImage: "square.and.arrow.down")
}
ShareButtonCompat($alert) {
Label("Share Content JSON File", systemImage: "curlybraces")
} itemURL: {
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
}
#endif
if profile.type == .remote {
@@ -856,24 +952,25 @@ private struct ProfilePickerRow: View {
Label("Share URL as QR Code", systemImage: "qrcode")
}
}
#if os(macOS)
Button {
shareItemType = .json
} label: {
Label("Share Content JSON File", systemImage: "curlybraces")
}
#else
ShareButtonCompat($alert) {
Label("Share Content JSON File", systemImage: "curlybraces")
} itemURL: {
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
}
#endif
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
private func exportProfile(type: ExportItemType) {
do {
switch type {
case .file:
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
showProfileExporter = true
case .json:
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
showJSONExporter = true
}
} catch {
alert = AlertState(error: error)
}
}
#endif
private var profileInfo: some View {
@@ -921,6 +1018,21 @@ private struct ProfilePickerRow: View {
}
}
private func exportProfileMacOS(type: ExportItemType) {
do {
switch type {
case .file:
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
showProfileExporter = true
case .json:
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
showJSONExporter = true
}
} catch {
alert = AlertState(error: error)
}
}
static func previewContent(profile: ProfilePreview, width: CGFloat) -> some View {
HStack(spacing: 12) {
Image(systemName: "line.3.horizontal")
@@ -965,6 +1077,15 @@ private struct ProfilePickerRow: View {
#endif
}
// MARK: - Export Helpers
#if !os(tvOS)
private enum ExportItemType {
case file
case json
}
#endif
// MARK: - macOS Helpers
#if os(macOS)
@@ -1015,6 +1136,10 @@ private struct ProfilePickerRow: View {
@State private var isUpdating = false
@State private var showQRCode = false
@State private var profileExportDocument: ProfileExportDocument?
@State private var showProfileExporter = false
@State private var profileJSONExportDocument: ProfileJSONExportDocument?
@State private var showJSONExporter = false
var body: some View {
Group {
@@ -1072,6 +1197,28 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.fileExporter(
isPresented: $showProfileExporter,
document: profileExportDocument,
contentType: .profile,
defaultFilename: profileExportDocument?.filename
) { result in
profileExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
.fileExporter(
isPresented: $showJSONExporter,
document: profileJSONExportDocument,
contentType: .json,
defaultFilename: profileJSONExportDocument?.filename
) { result in
profileJSONExportDocument = nil
if case let .failure(error) = result {
alert = AlertState(error: error)
}
}
}
private var rowMenu: some View {
@@ -1115,12 +1262,30 @@ private struct ProfilePickerRow: View {
@ViewBuilder
private var shareMenu: some View {
Menu {
Button {
exportProfile(type: .file)
} label: {
Label("Save File", systemImage: "square.and.arrow.down")
}
ShareButtonCompat($alert) {
Label("Share File", systemImage: "doc")
} itemURL: {
try profile.origin.toContent().generateShareFile()
}
Button {
exportProfile(type: .json)
} label: {
Label("Save Content JSON", systemImage: "square.and.arrow.down")
}
ShareButtonCompat($alert) {
Label("Share Content JSON File", systemImage: "curlybraces")
} itemURL: {
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
}
if profile.type == .remote {
Button {
showQRCode = true
@@ -1128,17 +1293,26 @@ private struct ProfilePickerRow: View {
Label("Share URL as QR Code", systemImage: "qrcode")
}
}
ShareButtonCompat($alert) {
Label("Share Content JSON File", systemImage: "curlybraces")
} itemURL: {
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
private func exportProfile(type: ExportItemType) {
do {
switch type {
case .file:
profileExportDocument = try ProfileExportDocument(content: profile.origin.toContent())
showProfileExporter = true
case .json:
profileJSONExportDocument = ProfileJSONExportDocument(jsonContent: try profile.origin.read(), name: profile.name)
showJSONExporter = true
}
} catch {
alert = AlertState(error: error)
}
}
private var profileInfo: some View {
HStack(spacing: 8) {
HStack(spacing: 4) {