Files
Archive/sing-box/clients/apple/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift
T
2026-02-21 19:48:39 +01:00

1439 lines
49 KiB
Swift

import Library
import QRCode
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
@MainActor
struct ProfilePickerSheet: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.dismiss) private var dismiss
@Binding var profileList: [ProfilePreview]
@Binding var selectedProfileID: Int64
#if !os(macOS)
@State private var editMode: EditMode = .inactive
#else
@State private var isEditing = false
@State private var rowWidth: CGFloat = 400
#endif
@State private var profileToEdit: Profile?
@State private var alert: AlertState?
#if os(tvOS)
@FocusState private var focusedProfileID: Int64?
@State private var movingProfileID: Int64?
#endif
private var isEditingActive: Bool {
#if !os(macOS)
editMode.isEditing
#else
isEditing
#endif
}
var body: some View {
#if os(iOS)
if #available(iOS 26, *) {
iOSBody
.environment(\.editMode, $editMode)
} else {
legacyIOSBody
.environment(\.editMode, $editMode)
}
#else
nonIOSBody
#endif
}
#if os(iOS)
@available(iOS 26, *)
private var iOSBody: some View {
iOSListContent
.toolbar {
ToolbarItem(placement: .primaryAction) {
EditButton()
}
}
.sheet(item: $profileToEdit) { profile in
NavigationSheet(title: "Edit Profile") {
EditProfileView()
.environmentObject(profile)
.environmentObject(environments)
}
}
.alert($alert)
}
@available(iOS 26, *)
@ViewBuilder
private var iOSListContent: some View {
if editMode.isEditing {
iOSEditingList
} else {
iOSNormalList
}
}
@available(iOS 26, *)
private var iOSEditingList: some View {
listContent
}
@available(iOS 26, *)
private var iOSNormalList: some View {
List {
ForEach(profileList, id: \.id) { profile in
ProfilePickerRow(
profile: profile,
isSelected: profile.id == selectedProfileID,
alert: $alert,
onSelect: {
selectedProfileID = profile.id
dismiss()
},
onEdit: {
profileToEdit = profile.origin
},
onUpdate: {
await updateProfile(profile)
}
)
.environmentObject(environments)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
}
}
.listStyle(.plain)
}
private var legacyIOSBody: some View {
legacyListContent
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(editMode.isEditing ? "Done" : "Edit") {
withAnimation {
editMode = editMode.isEditing ? .inactive : .active
}
}
}
}
.sheet(item: $profileToEdit) { profile in
NavigationSheet(title: "Edit Profile") {
EditProfileView()
.environmentObject(profile)
.environmentObject(environments)
}
}
.alert($alert)
}
#endif
#if !os(iOS)
private var nonIOSBody: some View {
listContent
#if os(tvOS)
.environment(\.editMode, $editMode)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
TVToolbarButton(title: editMode.isEditing ? String(localized: "Done") : String(localized: "Edit")) {
withAnimation {
if editMode.isEditing {
movingProfileID = nil
}
editMode = editMode.isEditing ? .inactive : .active
}
}
}
}
.navigationDestination(item: $profileToEdit) { profile in
EditProfileView()
.environmentObject(profile)
.environmentObject(environments)
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
BackButton()
}
}
}
#elseif os(macOS)
.safeAreaInset(edge: .bottom) {
VStack(spacing: 0) {
Divider()
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.escape, modifiers: [])
if isEditing {
Button("Done") {
withAnimation {
isEditing = false
}
}
.buttonStyle(.borderedProminent)
} else {
Button("Edit") {
withAnimation {
isEditing = true
}
}
.buttonStyle(.borderedProminent)
}
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
}
}
.sheet(item: $profileToEdit) { profile in
NavigationSheet {
EditProfileView()
.environmentObject(profile)
.environmentObject(environments)
}
.frame(minWidth: 500, minHeight: 400)
}
#endif
.alert($alert)
}
#endif
private var listContent: some View {
#if os(tvOS)
ScrollView {
LazyVStack(spacing: 12) {
ForEach(profileList, id: \.id) { profile in
ProfilePickerRow(
profile: profile,
isSelected: profile.id == selectedProfileID,
isMoving: movingProfileID == profile.id,
alert: $alert,
focusedProfileID: $focusedProfileID,
onSelect: {
selectedProfileID = profile.id
dismiss()
},
onEdit: {
profileToEdit = profile.origin
},
onUpdate: {
await updateProfile(profile)
},
onDelete: {
if let index = profileList.firstIndex(where: { $0.id == profile.id }) {
deleteProfile(at: IndexSet(integer: index))
}
},
onToggleMoving: {
withAnimation {
if movingProfileID == profile.id {
movingProfileID = nil
} else {
movingProfileID = profile.id
}
}
}
)
.environmentObject(environments)
}
}
.padding()
}
.onAppear {
focusedProfileID = selectedProfileID
}
.onMoveCommand { direction in
guard let movingID = movingProfileID,
let currentIndex = profileList.firstIndex(where: { $0.id == movingID })
else { return }
let newIndex: Int
switch direction {
case .up:
guard currentIndex > 0 else { return }
newIndex = currentIndex - 1
case .down:
guard currentIndex < profileList.count - 1 else { return }
newIndex = currentIndex + 1
default:
return
}
withAnimation {
moveProfile(from: IndexSet(integer: currentIndex), to: newIndex > currentIndex ? newIndex + 1 : newIndex)
}
focusedProfileID = movingID
}
#elseif os(macOS)
List {
ForEach(profileList, id: \.id) { profile in
macOSProfileRow(profile)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 3, leading: 16, bottom: 3, trailing: 16))
}
}
.listStyle(.plain)
#else
List {
ForEach(profileList, id: \.id) { profile in
ProfilePickerRow(
profile: profile,
isSelected: profile.id == selectedProfileID,
alert: $alert,
onSelect: {
selectedProfileID = profile.id
dismiss()
},
onEdit: {
profileToEdit = profile.origin
},
onUpdate: {
await updateProfile(profile)
}
)
.environmentObject(environments)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
}
.onMove(perform: moveProfile)
.onDelete(perform: deleteProfile)
}
.listStyle(.plain)
#endif
}
#if os(iOS)
@ViewBuilder
private var legacyListContent: some View {
if editMode.isEditing {
legacyEditingList
} else {
legacyNormalList
}
}
private var legacyEditingList: some View {
List {
ForEach(profileList, id: \.id) { profile in
LegacyProfilePickerRow(
profile: profile,
isSelected: profile.id == selectedProfileID,
alert: $alert,
onSelect: {
selectedProfileID = profile.id
dismiss()
},
onEdit: {
profileToEdit = profile.origin
},
onUpdate: {
await updateProfile(profile)
}
)
.environmentObject(environments)
}
.onMove(perform: legacyMoveProfile)
.onDelete(perform: legacyDeleteProfile)
}
}
private var legacyNormalList: some View {
List {
ForEach(profileList, id: \.id) { profile in
LegacyProfilePickerRow(
profile: profile,
isSelected: profile.id == selectedProfileID,
alert: $alert,
onSelect: {
selectedProfileID = profile.id
dismiss()
},
onEdit: {
profileToEdit = profile.origin
},
onUpdate: {
await updateProfile(profile)
}
)
.environmentObject(environments)
}
}
}
private func legacyMoveProfile(from source: IndexSet, to destination: Int) {
profileList.move(fromOffsets: source, toOffset: destination)
for (index, profile) in profileList.enumerated() {
profileList[index].order = UInt32(index)
profile.origin.order = UInt32(index)
}
Task {
do {
try await ProfileManager.update(profileList.map(\.origin))
environments.profileUpdate.send()
} catch {
// Handle error silently
}
}
}
private func legacyDeleteProfile(at offsets: IndexSet) {
let profilesToDelete = offsets.map { profileList[$0].origin }
profileList.remove(atOffsets: offsets)
Task {
do {
_ = try await ProfileManager.delete(profilesToDelete)
environments.emptyProfiles = profileList.isEmpty
environments.profileUpdate.send()
} catch {
// Handle error silently
}
}
}
#endif
private func updateProfile(_ profile: ProfilePreview) async {
do {
try await profile.origin.updateRemoteProfile()
environments.profileUpdate.send()
} catch {
alert = AlertState(action: "update remote profile", error: error)
}
}
private func moveProfile(from source: IndexSet, to destination: Int) {
profileList.move(fromOffsets: source, toOffset: destination)
for (index, profile) in profileList.enumerated() {
profileList[index].order = UInt32(index)
profile.origin.order = UInt32(index)
}
Task {
do {
try await ProfileManager.update(profileList.map(\.origin))
environments.profileUpdate.send()
} catch {
// Handle error silently
}
}
}
private func deleteProfile(at offsets: IndexSet) {
let profilesToDelete = offsets.map { profileList[$0].origin }
profileList.remove(atOffsets: offsets)
Task {
do {
_ = try await ProfileManager.delete(profilesToDelete)
environments.emptyProfiles = profileList.isEmpty
environments.profileUpdate.send()
} catch {
// Handle error silently
}
}
}
#if os(macOS)
private func macOSProfileRow(_ profile: ProfilePreview) -> some View {
ProfilePickerRow(
profile: profile,
isSelected: profile.id == selectedProfileID,
isEditing: isEditingActive,
alert: $alert,
onSelect: {
selectedProfileID = profile.id
dismiss()
},
onEdit: {
profileToEdit = profile.origin
},
onUpdate: {
await updateProfile(profile)
},
onDelete: {
if let index = profileList.firstIndex(where: { $0.id == profile.id }) {
deleteProfile(at: IndexSet(integer: index))
}
}
)
.environmentObject(environments)
.background {
GeometryReader { geometry in
Color.clear.preference(key: RowWidthKey.self, value: geometry.size.width)
}
}
.onPreferenceChange(RowWidthKey.self) { rowWidth = $0 }
.draggable(String(profile.id)) {
ProfilePickerRow.previewContent(profile: profile, width: rowWidth)
}
.dropDestination(for: String.self) { items, _ in
handleDrop(items: items, targetID: profile.id)
}
}
private func handleDrop(items: [String], targetID: Int64) -> Bool {
guard let draggedIDString = items.first,
let draggedID = Int64(draggedIDString),
let fromIndex = profileList.firstIndex(where: { $0.id == draggedID }),
let toIndex = profileList.firstIndex(where: { $0.id == targetID }),
fromIndex != toIndex
else {
return false
}
moveProfile(from: IndexSet(integer: fromIndex), to: toIndex > fromIndex ? toIndex + 1 : toIndex)
return true
}
#endif
}
// MARK: - ProfilePickerRow
private struct ProfilePickerRow: View {
@EnvironmentObject private var environments: ExtensionEnvironments
#if !os(macOS)
@Environment(\.editMode) private var editMode
#endif
let profile: ProfilePreview
let isSelected: Bool
#if os(macOS)
let isEditing: Bool
#endif
#if os(tvOS)
let isMoving: Bool
#endif
@Binding var alert: AlertState?
#if os(tvOS)
var focusedProfileID: FocusState<Int64?>.Binding
#endif
let onSelect: () -> Void
let onEdit: () -> Void
let onUpdate: () async -> Void
#if os(tvOS) || os(macOS)
let onDelete: () -> Void
#endif
#if os(tvOS)
let onToggleMoving: () -> Void
#endif
#if !os(macOS)
private var isEditing: Bool {
editMode?.wrappedValue.isEditing ?? false
}
#endif
@State private var isUpdating = false
@State private var showQRCode = false
@State private var showQRSShare = false
@State private var qrsShareData: Data?
#if os(macOS)
@State private var shareItemType: ShareItemType?
@State private var exportItemType: ExportItemType?
@State private var menuAnchorView: NSView?
#endif
#if !os(tvOS)
@State private var exportDocument: ProfileAnyExportDocument?
@State private var showExporter = false
#endif
var body: some View {
#if os(tvOS)
tvOSBody
#else
defaultBody
#endif
}
#if os(tvOS)
private var tvOSBody: some View {
Group {
if isEditing {
tvOSEditingBody
} else {
tvOSNormalBody
}
}
.sheet(isPresented: $showQRCode) {
if let remoteURL = profile.remoteURL {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.sheet(
isPresented: $showQRSShare,
onDismiss: {
qrsShareData = nil
},
content: {
if let data = qrsShareData {
QRSSheet(profileName: profile.name, profileData: data)
} else {
ProgressView()
}
}
)
}
private var tvOSNormalBody: some View {
Button(action: onSelect) {
HStack(spacing: 12) {
Image(systemName: "checkmark")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.tint)
.opacity(isSelected ? 1 : 0)
.frame(width: 28)
VStack(alignment: .leading, spacing: 8) {
Text(profile.name)
.font(.headline)
.foregroundStyle(.primary)
profileInfo
}
Spacer()
Color.clear.frame(width: 70)
}
.padding()
}
.buttonStyle(.card)
.focused(focusedProfileID, equals: profile.id)
.disabled(isUpdating)
.overlay(alignment: .trailing) {
Menu {
Button {
onEdit()
} label: {
Label("Edit", systemImage: "pencil")
}
if profile.type == .remote {
Button {
isUpdating = true
Task {
await onUpdate()
isUpdating = false
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
}
Menu {
if profile.type == .remote {
Button {
showQRCode = true
} label: {
Label("Share URL as QR Code", systemImage: "qrcode")
}
}
Button {
prepareQRSShare()
} label: {
Label("Share as QRS Code", systemImage: "barcode")
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 16))
}
.buttonStyle(.plain)
.actionButtonStyle()
.disabled(isUpdating)
.padding(.trailing, 12)
}
}
private var tvOSEditingBody: some View {
HStack(spacing: 12) {
Color.clear.frame(width: 28)
VStack(alignment: .leading, spacing: 8) {
Text(profile.name)
.font(.headline)
.foregroundStyle(.primary)
profileInfo
}
Spacer()
Button {
onDelete()
} label: {
Image(systemName: "minus.circle.fill")
.font(.system(size: 24))
.foregroundStyle(.red)
}
.buttonStyle(.plain)
.actionButtonStyle()
Button {
onToggleMoving()
} label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 20))
}
.buttonStyle(.plain)
.actionButtonStyle()
.focused(focusedProfileID, equals: profile.id)
}
.padding()
.background(Color.secondary.opacity(0.2), in: RoundedRectangle(cornerRadius: 16))
.scaleEffect(isMoving ? 1.05 : 1.0)
.animation(.easeInOut(duration: 0.2), value: isMoving)
}
#endif
#if !os(tvOS)
@ViewBuilder
private var defaultBody: some View {
#if os(macOS)
if isEditing {
macOSEditingBody
} else {
macOSNormalBody
}
#else
iOSBody
#endif
}
#if os(macOS)
private var macOSNormalBody: some View {
Button {
if !isUpdating {
onSelect()
}
} label: {
rowContent
}
.buttonStyle(.plain)
.disabled(isUpdating)
.sheet(isPresented: $showQRCode) {
if let remoteURL = profile.remoteURL {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.sheet(
isPresented: $showQRSShare,
onDismiss: {
qrsShareData = nil
},
content: {
if let data = qrsShareData {
QRSSheet(profileName: profile.name, profileData: data)
} else {
ProgressView()
}
}
)
.fileExporter(
isPresented: $showExporter,
document: exportDocument,
contentType: exportDocument?.contentType ?? .data,
defaultFilename: exportDocument?.filename
) { result in
exportDocument = nil
if case let .failure(error) = result {
alert = AlertState(action: "export profile", error: error)
}
}
}
private var macOSEditingBody: some View {
rowContent
}
#endif
#if os(iOS)
private var iOSBody: some View {
Button {
if !isEditing, !isUpdating {
onSelect()
}
} label: {
rowContent
}
.buttonStyle(.plain)
.disabled(isEditing || isUpdating)
.sheet(isPresented: $showQRCode) {
if let remoteURL = profile.remoteURL {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.sheet(
isPresented: $showQRSShare,
onDismiss: {
qrsShareData = nil
},
content: {
if let data = qrsShareData {
QRSSheet(profileName: profile.name, profileData: data)
} else {
ProgressView()
}
}
)
.fileExporter(
isPresented: $showExporter,
document: exportDocument,
contentType: exportDocument?.contentType ?? .data,
defaultFilename: exportDocument?.filename
) { result in
exportDocument = nil
if case let .failure(error) = result {
alert = AlertState(action: "export profile", error: error)
}
}
}
#endif
private var rowContent: some View {
HStack(spacing: 12) {
#if os(macOS)
Group {
if isEditing {
Image(systemName: "line.3.horizontal")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.secondary)
} else {
Image(systemName: "checkmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.tint)
.opacity(isSelected ? 1 : 0)
}
}
.frame(width: 16)
#else
if !isEditing {
Image(systemName: "checkmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.tint)
.opacity(isSelected ? 1 : 0)
}
#endif
VStack(alignment: .leading, spacing: 4) {
Text(profile.name)
.font(.body)
.foregroundStyle(.primary)
profileInfo
}
Spacer()
#if os(macOS)
if isEditing {
Button {
onDelete()
} label: {
Image(systemName: "minus.circle.fill")
.font(.system(size: 20))
.foregroundStyle(.red)
}
.buttonStyle(.plain)
} else {
rowMenu
}
#else
if !isEditing {
rowMenu
}
#endif
}
.contentShape(Rectangle())
.padding(16)
.cardStyle()
}
#endif
private var rowMenu: some View {
Menu {
Button {
onEdit()
} label: {
Label("Edit", systemImage: "pencil")
}
if profile.type == .remote {
Button {
isUpdating = true
Task {
await onUpdate()
isUpdating = false
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
}
#if !os(tvOS)
shareMenu
#endif
} label: {
Group {
if isUpdating {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "ellipsis")
.font(.system(size: 16))
.foregroundStyle(.secondary)
}
}
.frame(width: 32, height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
#if os(macOS)
.background(ViewAnchor { menuAnchorView = $0 })
.onChange(of: shareItemType) { shareItemType in
guard let shareItemType else { return }
self.shareItemType = nil
shareProfile(type: shareItemType)
}
.onChange(of: exportItemType) { exportItemType in
guard let exportItemType else { return }
self.exportItemType = nil
exportProfileMacOS(type: exportItemType)
}
#endif
}
private func prepareQRSShare() {
qrsShareData = nil
showQRSShare = true
Task {
do {
let data = try await profile.origin.encodedContentDataAsync()
await MainActor.run {
qrsShareData = data
}
} catch {
await MainActor.run {
alert = AlertState(action: "prepare QRS share", error: error)
showQRSShare = false
}
}
}
}
#if !os(tvOS)
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 await profile.origin.generateShareFileAsync()
}
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 await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
}
#endif
if profile.type == .remote {
Button {
showQRCode = true
} label: {
Label("Share URL as QR Code", systemImage: "qrcode")
}
}
Button {
prepareQRSShare()
} label: {
Label("Share as QRS Code", systemImage: "barcode")
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
private func exportProfile(type: ExportItemType) {
Task {
do {
let document: ProfileAnyExportDocument
switch type {
case .file:
let data = try await profile.origin.encodedContentDataAsync()
document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
case .json:
let content = try await profile.origin.readAsync()
document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
}
await MainActor.run {
exportDocument = document
showExporter = true
}
} catch {
await MainActor.run {
alert = AlertState(action: "export profile", error: error)
}
}
}
}
#endif
private var profileInfo: some View {
HStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: profile.type.presentationSymbol)
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(profile.type.presentationLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
if profile.type == .remote, let lastUpdated = profile.lastUpdated {
HStack(spacing: 4) {
Image(systemName: "clock.fill")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(lastUpdated.relativeFormat)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
#if os(macOS)
private func shareProfile(type: ShareItemType) {
Task {
do {
let url: URL
switch type {
case .file:
url = try await profile.origin.generateShareFileAsync()
case .json:
url = try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
}
await MainActor.run {
let anchorView = menuAnchorView ?? NSApp.keyWindow?.contentView ?? NSView()
NSSharingServicePicker(items: [url]).show(
relativeTo: .zero,
of: anchorView,
preferredEdge: .minY
)
}
} catch {
await MainActor.run {
alert = AlertState(action: "share profile", error: error)
}
}
}
}
private func exportProfileMacOS(type: ExportItemType) {
Task {
do {
let document: ProfileAnyExportDocument
switch type {
case .file:
let data = try await profile.origin.encodedContentDataAsync()
document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
case .json:
let content = try await profile.origin.readAsync()
document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
}
await MainActor.run {
exportDocument = document
showExporter = true
}
} catch {
await MainActor.run {
alert = AlertState(action: "export profile", error: error)
}
}
}
}
static func previewContent(profile: ProfilePreview, width: CGFloat) -> some View {
HStack(spacing: 12) {
Image(systemName: "line.3.horizontal")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: 16)
VStack(alignment: .leading, spacing: 4) {
Text(profile.name)
.font(.body)
HStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: profile.type.presentationSymbol)
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(profile.type.presentationLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
if profile.type == .remote, let lastUpdated = profile.lastUpdated {
HStack(spacing: 4) {
Image(systemName: "clock.fill")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(lastUpdated.relativeFormat)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
Spacer()
}
.frame(width: width)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.background, in: RoundedRectangle(cornerRadius: 8))
}
#endif
}
// MARK: - Export Helpers
#if !os(tvOS)
private enum ExportItemType {
case file
case json
}
#endif
// MARK: - macOS Helpers
#if os(macOS)
private struct RowWidthKey: PreferenceKey {
static var defaultValue: CGFloat = 400
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
private enum ShareItemType {
case file
case json
}
private struct ViewAnchor: NSViewRepresentable {
let callback: (NSView) -> Void
func makeNSView(context _: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
callback(view)
}
return view
}
func updateNSView(_: NSView, context _: Context) {}
}
#endif
// MARK: - Legacy iOS ProfilePickerRow (iOS < 26)
#if os(iOS)
private struct LegacyProfilePickerRow: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.editMode) private var editMode
let profile: ProfilePreview
let isSelected: Bool
@Binding var alert: AlertState?
let onSelect: () -> Void
let onEdit: () -> Void
let onUpdate: () async -> Void
private var isEditing: Bool {
editMode?.wrappedValue.isEditing ?? false
}
@State private var isUpdating = false
@State private var showQRCode = false
@State private var showQRSShare = false
@State private var qrsShareData: Data?
@State private var exportDocument: ProfileAnyExportDocument?
@State private var showExporter = false
var body: some View {
Group {
if isEditing {
editingBody
} else {
normalBody
}
}
.transaction { $0.animation = nil }
}
private var editingBody: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(profile.name)
.font(.body)
.foregroundStyle(.primary)
profileInfo
}
Spacer()
}
}
private var normalBody: some View {
HStack(spacing: 12) {
Button {
if !isUpdating {
onSelect()
}
} label: {
HStack(spacing: 12) {
Image(systemName: "checkmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.tint)
.opacity(isSelected ? 1 : 0)
VStack(alignment: .leading, spacing: 4) {
Text(profile.name)
.font(.body)
.foregroundStyle(.primary)
profileInfo
}
}
}
.buttonStyle(.plain)
.disabled(isUpdating)
Spacer()
rowMenu
}
.sheet(isPresented: $showQRCode) {
if let remoteURL = profile.remoteURL {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
}
}
.sheet(
isPresented: $showQRSShare,
onDismiss: {
qrsShareData = nil
},
content: {
if let data = qrsShareData {
QRSSheet(profileName: profile.name, profileData: data)
} else {
ProgressView()
}
}
)
.fileExporter(
isPresented: $showExporter,
document: exportDocument,
contentType: exportDocument?.contentType ?? .data,
defaultFilename: exportDocument?.filename
) { result in
exportDocument = nil
if case let .failure(error) = result {
alert = AlertState(action: "export profile", error: error)
}
}
}
private var rowMenu: some View {
Menu {
Button {
onEdit()
} label: {
Label("Edit", systemImage: "pencil")
}
if profile.type == .remote {
Button {
isUpdating = true
Task {
await onUpdate()
isUpdating = false
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
}
shareMenu
} label: {
Group {
if isUpdating {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "ellipsis")
.font(.system(size: 16))
.foregroundStyle(.secondary)
}
}
.frame(width: 32, height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private func prepareQRSShare() {
qrsShareData = nil
showQRSShare = true
Task {
do {
let data = try await profile.origin.encodedContentDataAsync()
await MainActor.run {
qrsShareData = data
}
} catch {
await MainActor.run {
alert = AlertState(action: "prepare QRS share", error: error)
showQRSShare = false
}
}
}
}
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 await profile.origin.generateShareFileAsync()
}
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 await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
}
if profile.type == .remote {
Button {
showQRCode = true
} label: {
Label("Share URL as QR Code", systemImage: "qrcode")
}
}
Button {
prepareQRSShare()
} label: {
Label("Share as QRS Code", systemImage: "barcode")
}
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
private func exportProfile(type: ExportItemType) {
Task {
do {
let document: ProfileAnyExportDocument
switch type {
case .file:
let data = try await profile.origin.encodedContentDataAsync()
document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
case .json:
let content = try await profile.origin.readAsync()
document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
}
await MainActor.run {
exportDocument = document
showExporter = true
}
} catch {
await MainActor.run {
alert = AlertState(action: "export profile", error: error)
}
}
}
}
private var profileInfo: some View {
HStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: profile.type.presentationSymbol)
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(profile.type.presentationLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
if profile.type == .remote, let lastUpdated = profile.lastUpdated {
HStack(spacing: 4) {
Image(systemName: "clock.fill")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(lastUpdated.relativeFormat)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
#endif