Refactor profile management
This commit is contained in:
@@ -31,12 +31,12 @@ public extension View {
|
|||||||
ActionButtonWrapper { self }
|
ActionButtonWrapper { self }
|
||||||
#else
|
#else
|
||||||
if #available(iOS 26.0, macOS 26.0, *) {
|
if #available(iOS 26.0, macOS 26.0, *) {
|
||||||
self.frame(width: 36, height: 36)
|
self.frame(width: 44, height: 32)
|
||||||
.glassEffect(.regular.interactive(), in: .circle)
|
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 8))
|
||||||
} else {
|
} else {
|
||||||
frame(width: 36, height: 36)
|
frame(width: 44, height: 32)
|
||||||
.background(Color.secondary.opacity(0.1))
|
.background(Color.secondary.opacity(0.1))
|
||||||
.clipShape(Circle())
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -49,9 +49,9 @@ private struct ActionButtonWrapper<Content: View>: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content()
|
content()
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 70, height: 48)
|
||||||
.background(isFocused ? Color.secondary.opacity(0.3) : Color.secondary.opacity(0.1))
|
.background(isFocused ? Color.secondary.opacity(0.3) : Color.secondary.opacity(0.1))
|
||||||
.clipShape(Circle())
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
.focusEffectDisabled()
|
.focusEffectDisabled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import Libbox
|
|||||||
import Library
|
import Library
|
||||||
import QRCode
|
import QRCode
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public struct ProfileCard: View {
|
public struct ProfileCard: View {
|
||||||
@@ -34,20 +39,24 @@ public struct ProfileCard: View {
|
|||||||
.disabled(viewModel.isUpdating)
|
.disabled(viewModel.isUpdating)
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.navigationDestination(isPresented: $viewModel.showNewProfile) {
|
.navigationDestination(isPresented: $viewModel.showNewProfile) {
|
||||||
NewProfileContentView(onDisappear: {
|
NewProfileMenuView()
|
||||||
environments.profileUpdate.send()
|
|
||||||
})
|
|
||||||
.environmentObject(environments)
|
.environmentObject(environments)
|
||||||
|
.onDisappear {
|
||||||
|
environments.profileUpdate.send()
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .topBarLeading) {
|
ToolbarItemGroup(placement: .topBarLeading) {
|
||||||
BackButton()
|
BackButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(isPresented: $viewModel.showManageProfiles) {
|
.navigationDestination(isPresented: $viewModel.showProfilePicker) {
|
||||||
ManageProfilesView()
|
ProfilePickerSheet(
|
||||||
|
profileList: $profileList,
|
||||||
|
selectedProfileID: $selectedProfileID
|
||||||
|
)
|
||||||
.environmentObject(environments)
|
.environmentObject(environments)
|
||||||
.navigationTitle("Manage profiles")
|
.navigationTitle("Profiles")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .topBarLeading) {
|
ToolbarItemGroup(placement: .topBarLeading) {
|
||||||
BackButton()
|
BackButton()
|
||||||
@@ -76,8 +85,8 @@ public struct ProfileCard: View {
|
|||||||
NewProfileNavigationView()
|
NewProfileNavigationView()
|
||||||
.environmentObject(environments)
|
.environmentObject(environments)
|
||||||
})
|
})
|
||||||
.sheet(isPresented: $viewModel.showManageProfiles) {
|
.sheet(isPresented: $viewModel.showProfilePicker) {
|
||||||
manageProfilesSheet
|
profilePickerSheet
|
||||||
}
|
}
|
||||||
.sheet(item: $viewModel.profileToEdit) { profile in
|
.sheet(item: $viewModel.profileToEdit) { profile in
|
||||||
editProfileSheet(for: profile)
|
editProfileSheet(for: profile)
|
||||||
@@ -101,7 +110,6 @@ public struct ProfileCard: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack(spacing: actionButtonSpacing) {
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.showNewProfile = true
|
viewModel.showNewProfile = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -110,18 +118,6 @@ public struct ProfileCard: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.actionButtonStyle()
|
.actionButtonStyle()
|
||||||
|
|
||||||
if !profileList.isEmpty {
|
|
||||||
Button {
|
|
||||||
viewModel.showManageProfiles = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "line.3.horizontal")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.actionButtonStyle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +129,8 @@ public struct ProfileCard: View {
|
|||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
} else {
|
} else {
|
||||||
ProfileSelectorButton(
|
ProfileSelectorButton(
|
||||||
items: profileList,
|
|
||||||
selectedItem: selectedProfile,
|
selectedItem: selectedProfile,
|
||||||
onSelect: { selectedProfileID = $0 }
|
isPickerPresented: $viewModel.showProfilePicker
|
||||||
)
|
)
|
||||||
|
|
||||||
if let profile = selectedProfile {
|
if let profile = selectedProfile {
|
||||||
@@ -163,17 +158,9 @@ public struct ProfileCard: View {
|
|||||||
|
|
||||||
if profile.type == .remote {
|
if profile.type == .remote {
|
||||||
updateButton(for: profile)
|
updateButton(for: profile)
|
||||||
qrCodeButton(for: profile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
shareMenu(for: profile)
|
||||||
ProfileShareButton($viewModel.alert, profile.origin) {
|
|
||||||
Image(systemName: "square.and.arrow.up")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.actionButtonStyle()
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +227,112 @@ public struct ProfileCard: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func shareMenu(for profile: ProfilePreview) -> some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
if profile.type == .remote {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
viewModel.showQRCode = true
|
||||||
|
} label: {
|
||||||
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.actionButtonStyle()
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
viewModel.shareItemType = .file
|
||||||
|
} label: {
|
||||||
|
Label("Share File", systemImage: "doc")
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.type == .remote {
|
||||||
|
Button {
|
||||||
|
viewModel.showQRCode = true
|
||||||
|
} label: {
|
||||||
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.shareItemType = .json
|
||||||
|
} label: {
|
||||||
|
Label("Share Content JSON File", systemImage: "curlybraces")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
.menuIndicator(.hidden)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.actionButtonStyle()
|
||||||
|
.onChange(of: viewModel.shareItemType) { shareItemType in
|
||||||
|
guard let shareItemType else { return }
|
||||||
|
viewModel.shareItemType = nil
|
||||||
|
shareProfile(profile, type: shareItemType)
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.background(ViewAnchor { viewModel.shareButtonView = $0 })
|
||||||
|
.popover(isPresented: $viewModel.showQRCode, arrowEdge: .bottom) {
|
||||||
|
if let remoteURL = profile.remoteURL {
|
||||||
|
QRCodeContentView(profileName: profile.name, remoteURL: remoteURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
private func shareProfile(_ profile: ProfilePreview, type: ShareItemType) {
|
||||||
|
do {
|
||||||
|
let url: URL
|
||||||
|
switch type {
|
||||||
|
case .file:
|
||||||
|
url = try profile.origin.toContent().generateShareFile()
|
||||||
|
case .json:
|
||||||
|
url = try profile.origin.read().generateShareFile(name: "\(profile.name).json")
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
presentShareController(url)
|
||||||
|
#elseif os(macOS)
|
||||||
|
let anchorView = viewModel.shareButtonView ?? NSApp.keyWindow?.contentView ?? NSView()
|
||||||
|
NSSharingServicePicker(items: [url]).show(
|
||||||
|
relativeTo: .zero,
|
||||||
|
of: anchorView,
|
||||||
|
preferredEdge: .minY
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
} catch {
|
||||||
|
viewModel.alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private func presentShareController(_ item: URL) {
|
||||||
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
let rootViewController = windowScene.keyWindow?.rootViewController
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var topViewController = rootViewController
|
||||||
|
while let presented = topViewController.presentedViewController {
|
||||||
|
topViewController = presented
|
||||||
|
}
|
||||||
|
topViewController.present(
|
||||||
|
UIActivityViewController(activityItems: [item], applicationActivities: nil),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func profileInfo(for profile: ProfilePreview) -> some View {
|
private func profileInfo(for profile: ProfilePreview) -> some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -265,16 +358,23 @@ public struct ProfileCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var manageProfilesSheet: some View {
|
#if !os(tvOS)
|
||||||
|
private var profilePickerSheet: some View {
|
||||||
NavigationSheet(
|
NavigationSheet(
|
||||||
title: String(localized: "Manage profiles"),
|
title: String(localized: "Profiles"),
|
||||||
showDoneButton: true,
|
size: .large,
|
||||||
onDismiss: { viewModel.showManageProfiles = false },
|
|
||||||
content: {
|
content: {
|
||||||
ManageProfilesView()
|
ProfilePickerSheet(
|
||||||
|
profileList: $profileList,
|
||||||
|
selectedProfileID: $selectedProfileID
|
||||||
|
)
|
||||||
.environmentObject(environments)
|
.environmentObject(environments)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(minWidth: 400, minHeight: 300)
|
||||||
|
#endif
|
||||||
|
.modifier(OpaqueSheetBackground())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -294,19 +394,29 @@ public struct ProfileCard: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ViewModel
|
// MARK: - ViewModel
|
||||||
|
|
||||||
extension ProfileCard {
|
extension ProfileCard {
|
||||||
|
enum ShareItemType {
|
||||||
|
case file
|
||||||
|
case json
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var showNewProfile = false
|
@Published var showNewProfile = false
|
||||||
@Published var showManageProfiles = false
|
@Published var showProfilePicker = false
|
||||||
@Published var showQRCode = false
|
@Published var showQRCode = false
|
||||||
@Published var isUpdating = false
|
@Published var isUpdating = false
|
||||||
@Published var alert: AlertState?
|
@Published var alert: AlertState?
|
||||||
@Published var profileToEdit: Profile?
|
@Published var profileToEdit: Profile?
|
||||||
|
@Published var shareItemType: ShareItemType?
|
||||||
|
#if os(macOS)
|
||||||
|
var shareButtonView: NSView?
|
||||||
|
#endif
|
||||||
|
|
||||||
func updateProfile(_ profile: Profile, environments: ExtensionEnvironments) async {
|
func updateProfile(_ profile: Profile, environments: ExtensionEnvironments) async {
|
||||||
defer { isUpdating = false }
|
defer { isUpdating = false }
|
||||||
@@ -330,253 +440,61 @@ extension ProfileCard {
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct NewProfileNavigationView: View {
|
struct NewProfileNavigationView: View {
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
@State private var createdProfile: Profile?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
macOSBody
|
NavigationSheet {
|
||||||
#else
|
NewProfileMenuView()
|
||||||
iOSBody
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
@ViewBuilder
|
|
||||||
private var macOSBody: some View {
|
|
||||||
if let profile = createdProfile {
|
|
||||||
EditProfileView()
|
|
||||||
.environmentObject(profile)
|
|
||||||
.environmentObject(environments)
|
|
||||||
} else {
|
|
||||||
NewProfileView { profile in
|
|
||||||
createdProfile = profile
|
|
||||||
}
|
|
||||||
.environmentObject(environments)
|
.environmentObject(environments)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
private var iOSBody: some View {
|
|
||||||
NavigationStackCompat {
|
NavigationStackCompat {
|
||||||
if let profile = createdProfile {
|
NewProfileMenuView()
|
||||||
EditProfileView()
|
|
||||||
.environmentObject(profile)
|
|
||||||
.environmentObject(environments)
|
.environmentObject(environments)
|
||||||
} else {
|
|
||||||
NewProfileView { profile in
|
|
||||||
createdProfile = profile
|
|
||||||
}
|
|
||||||
.environmentObject(environments)
|
|
||||||
#if os(iOS)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.presentationDetentsIfAvailable()
|
.presentationDetentsIfAvailable()
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NewProfileContentView (tvOS)
|
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
extension ProfileCard {
|
|
||||||
@MainActor
|
|
||||||
struct NewProfileContentView: View {
|
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
|
||||||
@State private var createdProfile: Profile?
|
|
||||||
private let onDisappear: (() -> Void)?
|
|
||||||
|
|
||||||
init(onDisappear: (() -> Void)? = nil) {
|
|
||||||
self.onDisappear = onDisappear
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
// MARK: - OpaqueSheetBackground
|
||||||
Group {
|
|
||||||
if let profile = createdProfile {
|
#if os(iOS)
|
||||||
EditProfileView()
|
private struct OpaqueSheetBackground: ViewModifier {
|
||||||
.environmentObject(profile)
|
func body(content: Content) -> some View {
|
||||||
.environmentObject(environments)
|
if #available(iOS 16.4, *) {
|
||||||
|
content.presentationBackground(.regularMaterial)
|
||||||
} else {
|
} else {
|
||||||
NewProfileView { profile in
|
content
|
||||||
createdProfile = profile
|
|
||||||
}
|
|
||||||
.environmentObject(environments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
onDisappear?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - ManageProfilesView
|
|
||||||
|
|
||||||
extension ProfileCard {
|
|
||||||
@MainActor
|
|
||||||
struct ManageProfilesView: View {
|
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
|
||||||
@StateObject private var viewModel = ProfileViewModel()
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.onAppear {
|
|
||||||
viewModel.setEnvironments(environments)
|
|
||||||
Task {
|
|
||||||
await viewModel.doReload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
FormView {
|
|
||||||
if viewModel.profileList.isEmpty {
|
|
||||||
Text("Empty profiles")
|
|
||||||
} else {
|
|
||||||
List {
|
|
||||||
ForEach(viewModel.profileList, id: \.id) { profile in
|
|
||||||
ManageProfileItem(viewModel, profile)
|
|
||||||
}
|
|
||||||
.onMove(perform: moveProfile)
|
|
||||||
#if os(macOS)
|
|
||||||
.onDelete(perform: deleteProfile)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
.environment(\.editMode, .constant(.active))
|
|
||||||
.deleteDisabled(true)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isUpdating)
|
|
||||||
.alert($viewModel.alert, isLoading: $viewModel.isLoading)
|
|
||||||
.onReceive(environments.profileUpdate) { _ in
|
|
||||||
Task {
|
|
||||||
await viewModel.doReload()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func moveProfile(from source: IndexSet, to destination: Int) {
|
#elseif os(macOS)
|
||||||
viewModel.moveProfile(from: source, to: destination)
|
private struct OpaqueSheetBackground: ViewModifier {
|
||||||
}
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
private func deleteProfile(where profileIndex: IndexSet) {
|
|
||||||
viewModel.deleteProfile(where: profileIndex)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
private struct ViewAnchor: NSViewRepresentable {
|
||||||
struct ManageProfileItem: View {
|
let callback: (NSView) -> Void
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
|
||||||
@ObservedObject private var viewModel: ProfileViewModel
|
|
||||||
@State private var profile: ProfilePreview
|
|
||||||
@State private var shareLinkPresented = false
|
|
||||||
@State private var isUpdating = false
|
|
||||||
|
|
||||||
init(_ viewModel: ProfileViewModel, _ profile: ProfilePreview) {
|
func makeNSView(context _: Context) -> NSView {
|
||||||
self.viewModel = viewModel
|
let view = NSView()
|
||||||
_profile = State(initialValue: profile)
|
DispatchQueue.main.async {
|
||||||
|
callback(view)
|
||||||
|
}
|
||||||
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
private var actionButtonSpacing: CGFloat {
|
func updateNSView(_: NSView, context _: Context) {}
|
||||||
#if os(tvOS)
|
}
|
||||||
24
|
|
||||||
#else
|
#else
|
||||||
8
|
private struct OpaqueSheetBackground: ViewModifier {
|
||||||
#endif
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "line.3.horizontal")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text(profile.name)
|
|
||||||
#if os(macOS)
|
|
||||||
if profile.type == .remote {
|
|
||||||
Spacer(minLength: 4)
|
|
||||||
Text("Last Updated: \(profile.origin.lastUpdated!.myFormat)").font(.caption)
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
HStack(spacing: actionButtonSpacing) {
|
|
||||||
if profile.type == .remote {
|
|
||||||
Button {
|
|
||||||
isUpdating = true
|
|
||||||
Task {
|
|
||||||
await viewModel.updateProfile(profile.origin)
|
|
||||||
profile = ProfilePreview(profile.origin)
|
|
||||||
isUpdating = false
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
.rotationEffect(.degrees(isUpdating ? 360 : 0))
|
|
||||||
.animation(
|
|
||||||
isUpdating
|
|
||||||
? .linear(duration: 1).repeatForever(autoreverses: false)
|
|
||||||
: .default,
|
|
||||||
value: isUpdating
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.actionButtonStyle()
|
|
||||||
.disabled(isUpdating)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
shareLinkPresented = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "qrcode")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.actionButtonStyle()
|
|
||||||
#if os(macOS)
|
|
||||||
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
|
|
||||||
QRCodeContentView(profileName: profile.name, remoteURL: profile.remoteURL!)
|
|
||||||
}
|
|
||||||
#elseif os(iOS) || os(tvOS)
|
|
||||||
.sheet(isPresented: $shareLinkPresented) {
|
|
||||||
QRCodeSheet(profileName: profile.name, remoteURL: profile.remoteURL!)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
ShareButtonCompat($viewModel.alert) {
|
|
||||||
Image(systemName: "square.and.arrow.up")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
} itemURL: {
|
|
||||||
try profile.origin.toContent().generateShareFile()
|
|
||||||
}
|
|
||||||
.actionButtonStyle()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteProfile(profile.origin)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.actionButtonStyle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,699 @@
|
|||||||
|
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(iOS) || os(tvOS)
|
||||||
|
@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?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private var isEditingActive: Bool {
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
editMode.isEditing
|
||||||
|
#else
|
||||||
|
isEditing
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
listContent
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
.environment(\.editMode, $editMode)
|
||||||
|
#endif
|
||||||
|
#if os(tvOS)
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
.alert($alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
isEditing: isEditingActive,
|
||||||
|
alert: $alert,
|
||||||
|
focusedProfileID: $focusedProfileID,
|
||||||
|
onSelect: {
|
||||||
|
selectedProfileID = profile.id
|
||||||
|
dismiss()
|
||||||
|
},
|
||||||
|
onEdit: {
|
||||||
|
profileToEdit = profile.origin
|
||||||
|
},
|
||||||
|
onUpdate: {
|
||||||
|
await updateProfile(profile)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.environmentObject(environments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
focusedProfileID = selectedProfileID
|
||||||
|
}
|
||||||
|
#elseif os(macOS)
|
||||||
|
List {
|
||||||
|
ForEach(profileList, id: \.id) { profile in
|
||||||
|
macOSProfileRow(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
List {
|
||||||
|
ForEach(profileList, id: \.id) { profile in
|
||||||
|
ProfilePickerRow(
|
||||||
|
profile: profile,
|
||||||
|
isSelected: profile.id == selectedProfileID,
|
||||||
|
isEditing: isEditingActive,
|
||||||
|
alert: $alert,
|
||||||
|
onSelect: {
|
||||||
|
selectedProfileID = profile.id
|
||||||
|
dismiss()
|
||||||
|
},
|
||||||
|
onEdit: {
|
||||||
|
profileToEdit = profile.origin
|
||||||
|
},
|
||||||
|
onUpdate: {
|
||||||
|
await updateProfile(profile)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.environmentObject(environments)
|
||||||
|
}
|
||||||
|
.onMove(perform: moveProfile)
|
||||||
|
.onDelete(perform: deleteProfile)
|
||||||
|
.moveDisabled(!isEditingActive)
|
||||||
|
.deleteDisabled(!isEditingActive)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateProfile(_ profile: ProfilePreview) async {
|
||||||
|
do {
|
||||||
|
try await profile.origin.updateRemoteProfile()
|
||||||
|
environments.profileUpdate.send()
|
||||||
|
} catch {
|
||||||
|
alert = AlertState(
|
||||||
|
title: String(localized: "Update Failed"),
|
||||||
|
message: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
@ViewBuilder
|
||||||
|
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
|
||||||
|
|
||||||
|
let profile: ProfilePreview
|
||||||
|
let isSelected: Bool
|
||||||
|
let isEditing: Bool
|
||||||
|
@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(macOS)
|
||||||
|
let onDelete: () -> Void
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@State private var isUpdating = false
|
||||||
|
@State private var showQRCode = false
|
||||||
|
#if os(macOS)
|
||||||
|
@State private var shareItemType: ShareItemType?
|
||||||
|
@State private var menuAnchorView: NSView?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
tvOSBody
|
||||||
|
#else
|
||||||
|
defaultBody
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
private var tvOSBody: some View {
|
||||||
|
Button(action: onSelect) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 24, weight: .semibold))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.opacity(isSelected ? 1 : 0)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(profile.name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
profileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !isEditing {
|
||||||
|
Color.clear.frame(width: 44)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
.focused(focusedProfileID, equals: profile.id)
|
||||||
|
.disabled(isEditing || isUpdating)
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
if !isEditing {
|
||||||
|
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 {
|
||||||
|
Button {
|
||||||
|
showQRCode = true
|
||||||
|
} label: {
|
||||||
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.actionButtonStyle()
|
||||||
|
.disabled(isUpdating)
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showQRCode) {
|
||||||
|
if let remoteURL = profile.remoteURL {
|
||||||
|
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var macOSEditingBody: some View {
|
||||||
|
rowContent
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
Button {
|
||||||
|
onDelete()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#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
|
||||||
|
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 !isEditing {
|
||||||
|
rowMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
#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)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
@ViewBuilder
|
||||||
|
private var shareMenu: some View {
|
||||||
|
Menu {
|
||||||
|
#if os(macOS)
|
||||||
|
Button {
|
||||||
|
shareItemType = .file
|
||||||
|
} label: {
|
||||||
|
Label("Share File", systemImage: "doc")
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
ShareButtonCompat($alert) {
|
||||||
|
Label("Share File", systemImage: "doc")
|
||||||
|
} itemURL: {
|
||||||
|
try profile.origin.toContent().generateShareFile()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if profile.type == .remote {
|
||||||
|
Button {
|
||||||
|
showQRCode = true
|
||||||
|
} label: {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private var profileInfo: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: profile.type == .remote ? "cloud.fill" : "doc.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(profile.type == .remote ? "Remote" : "Local")
|
||||||
|
.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.myFormat)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private func shareProfile(type: ShareItemType) {
|
||||||
|
do {
|
||||||
|
let url: URL
|
||||||
|
switch type {
|
||||||
|
case .file:
|
||||||
|
url = try profile.origin.toContent().generateShareFile()
|
||||||
|
case .json:
|
||||||
|
url = try profile.origin.read().generateShareFile(name: "\(profile.name).json")
|
||||||
|
}
|
||||||
|
let anchorView = menuAnchorView ?? NSApp.keyWindow?.contentView ?? NSView()
|
||||||
|
NSSharingServicePicker(items: [url]).show(
|
||||||
|
relativeTo: .zero,
|
||||||
|
of: anchorView,
|
||||||
|
preferredEdge: .minY
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
alert = AlertState(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 == .remote ? "cloud.fill" : "doc.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(profile.type == .remote ? "Remote" : "Local")
|
||||||
|
.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.myFormat)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(width: width)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.background, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
#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
|
||||||
@@ -1,274 +1,80 @@
|
|||||||
import Library
|
import Library
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// A custom view that looks like a navigation link but opens a dropdown menu
|
|
||||||
struct ProfileSelectorButton: View {
|
struct ProfileSelectorButton: View {
|
||||||
let items: [ProfilePreview]
|
|
||||||
let selectedItem: ProfilePreview?
|
let selectedItem: ProfilePreview?
|
||||||
let onSelect: (Int64) -> Void
|
@Binding var isPickerPresented: Bool
|
||||||
|
|
||||||
init(
|
|
||||||
items: [ProfilePreview],
|
|
||||||
selectedItem: ProfilePreview?,
|
|
||||||
onSelect: @escaping (Int64) -> Void
|
|
||||||
) {
|
|
||||||
self.items = items
|
|
||||||
self.selectedItem = selectedItem
|
|
||||||
self.onSelect = onSelect
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(iOS) || os(tvOS)
|
Button {
|
||||||
ProfileSelectorUIButton(
|
isPickerPresented = true
|
||||||
items: items,
|
} label: {
|
||||||
selectedItem: selectedItem,
|
HStack {
|
||||||
onSelect: onSelect
|
Text(selectedItem?.name ?? "Select Profile")
|
||||||
)
|
.font(.system(size: buttonFontSize, weight: .medium))
|
||||||
.frame(height: 44)
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.system(size: chevronSize, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: buttonHeight)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.buttonStyle(SelectorButtonStyle())
|
||||||
|
#else
|
||||||
|
.buttonStyle(.plain)
|
||||||
.selectorBackground()
|
.selectorBackground()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonHeight: CGFloat {
|
||||||
|
#if os(tvOS)
|
||||||
|
60
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
ProfileSelectorNSButton(
|
32
|
||||||
items: items,
|
#else
|
||||||
selectedItem: selectedItem,
|
44
|
||||||
onSelect: onSelect
|
|
||||||
)
|
|
||||||
.frame(height: 32)
|
|
||||||
.selectorBackground()
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UIKit Implementation (iOS/tvOS)
|
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
/// Custom UIButton subclass that allows customizing menu attachment point
|
|
||||||
private class MenuAttachmentButton: UIButton {
|
|
||||||
override func menuAttachmentPoint(for _: UIContextMenuConfiguration) -> CGPoint {
|
|
||||||
// Attach menu to bottom-leading corner of the button
|
|
||||||
CGPoint(x: 0, y: bounds.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// UIViewRepresentable wrapper for UIButton with UIMenu
|
|
||||||
private struct ProfileSelectorUIButton: UIViewRepresentable {
|
|
||||||
let items: [ProfilePreview]
|
|
||||||
let selectedItem: ProfilePreview?
|
|
||||||
let onSelect: (Int64) -> Void
|
|
||||||
|
|
||||||
func makeUIView(context _: Context) -> MenuAttachmentButton {
|
|
||||||
let button = MenuAttachmentButton(type: .system)
|
|
||||||
button.showsMenuAsPrimaryAction = true
|
|
||||||
button.contentHorizontalAlignment = .fill
|
|
||||||
|
|
||||||
// Configure button appearance
|
|
||||||
var config = UIButton.Configuration.plain()
|
|
||||||
config.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)
|
|
||||||
button.configuration = config
|
|
||||||
|
|
||||||
updateButtonContent(button)
|
|
||||||
updateMenu(button)
|
|
||||||
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ button: MenuAttachmentButton, context _: Context) {
|
|
||||||
updateButtonContent(button)
|
|
||||||
updateMenu(button)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateButtonContent(_ button: MenuAttachmentButton) {
|
|
||||||
// Remove existing subviews
|
|
||||||
for subview in button.subviews where subview is UIStackView {
|
|
||||||
subview.removeFromSuperview()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create content stack
|
|
||||||
let stackView = UIStackView()
|
|
||||||
stackView.axis = .horizontal
|
|
||||||
stackView.alignment = .center
|
|
||||||
stackView.distribution = .fill
|
|
||||||
stackView.spacing = 8
|
|
||||||
stackView.isUserInteractionEnabled = false
|
|
||||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
// Title label
|
|
||||||
let titleLabel = UILabel()
|
|
||||||
titleLabel.text = selectedItem?.name ?? "Select Profile"
|
|
||||||
titleLabel.font = .systemFont(ofSize: 17, weight: .medium)
|
|
||||||
titleLabel.textColor = .label
|
|
||||||
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
||||||
|
|
||||||
// Chevron image
|
|
||||||
let chevronConfig = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
||||||
let chevronImage = UIImage(systemName: "chevron.up.chevron.down", withConfiguration: chevronConfig)
|
|
||||||
let chevronView = UIImageView(image: chevronImage)
|
|
||||||
chevronView.tintColor = .secondaryLabel
|
|
||||||
chevronView.setContentHuggingPriority(.required, for: .horizontal)
|
|
||||||
|
|
||||||
stackView.addArrangedSubview(titleLabel)
|
|
||||||
stackView.addArrangedSubview(chevronView)
|
|
||||||
|
|
||||||
button.addSubview(stackView)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
stackView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
|
|
||||||
stackView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -14),
|
|
||||||
stackView.centerYAnchor.constraint(equalTo: button.centerYAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateMenu(_ button: MenuAttachmentButton) {
|
|
||||||
let actions = items.map { item in
|
|
||||||
UIAction(
|
|
||||||
title: item.name,
|
|
||||||
image: UIImage(systemName: item.type.iconName)
|
|
||||||
) { [item] _ in
|
|
||||||
onSelect(item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button.menu = UIMenu(children: actions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - AppKit Implementation (macOS)
|
|
||||||
|
|
||||||
|
private var buttonFontSize: CGFloat {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// NSViewRepresentable wrapper for a clickable view with NSMenu
|
13
|
||||||
private struct ProfileSelectorNSButton: NSViewRepresentable {
|
#else
|
||||||
let items: [ProfilePreview]
|
17
|
||||||
let selectedItem: ProfilePreview?
|
#endif
|
||||||
let onSelect: (Int64) -> Void
|
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSView {
|
|
||||||
let containerView = MenuContainerView()
|
|
||||||
containerView.coordinator = context.coordinator
|
|
||||||
updateContent(containerView)
|
|
||||||
context.coordinator.updateMenu(items: items, onSelect: onSelect)
|
|
||||||
return containerView
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ nsView: NSView, context: Context) {
|
private var chevronSize: CGFloat {
|
||||||
guard let containerView = nsView as? MenuContainerView else { return }
|
#if os(macOS)
|
||||||
updateContent(containerView)
|
10
|
||||||
context.coordinator.updateMenu(items: items, onSelect: onSelect)
|
#else
|
||||||
}
|
12
|
||||||
|
#endif
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateContent(_ containerView: MenuContainerView) {
|
|
||||||
containerView.titleText = selectedItem?.name ?? "Select Profile"
|
|
||||||
}
|
|
||||||
|
|
||||||
private class MenuContainerView: NSView {
|
|
||||||
weak var coordinator: Coordinator?
|
|
||||||
private let titleLabel = NSTextField(labelWithString: "")
|
|
||||||
private let chevronView = NSImageView()
|
|
||||||
|
|
||||||
var titleText: String = "" {
|
|
||||||
didSet {
|
|
||||||
titleLabel.stringValue = titleText
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame frameRect: NSRect) {
|
#if os(tvOS)
|
||||||
super.init(frame: frameRect)
|
private struct SelectorButtonStyle: ButtonStyle {
|
||||||
setupViews()
|
@Environment(\.isFocused) private var isFocused
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
super.init(coder: coder)
|
configuration.label
|
||||||
setupViews()
|
.background(
|
||||||
}
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(isFocused ? Color.white : Color.secondary.opacity(0.1))
|
||||||
private func setupViews() {
|
|
||||||
titleLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
|
||||||
titleLabel.textColor = .labelColor
|
|
||||||
titleLabel.alignment = .left
|
|
||||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addSubview(titleLabel)
|
|
||||||
|
|
||||||
let chevronConfig = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
|
|
||||||
chevronView.image = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: nil)?
|
|
||||||
.withSymbolConfiguration(chevronConfig)
|
|
||||||
chevronView.contentTintColor = .secondaryLabelColor
|
|
||||||
chevronView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addSubview(chevronView)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
|
|
||||||
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
||||||
chevronView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
|
|
||||||
chevronView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
||||||
titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevronView.leadingAnchor, constant: -8),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
override func mouseDown(with _: NSEvent) {
|
|
||||||
coordinator?.showMenu(in: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func resetCursorRects() {
|
|
||||||
addCursorRect(bounds, cursor: .pointingHand)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Coordinator: NSObject {
|
|
||||||
private var menu = NSMenu()
|
|
||||||
private var onSelectCallback: ((Int64) -> Void)?
|
|
||||||
|
|
||||||
func updateMenu(items: [ProfilePreview], onSelect: @escaping (Int64) -> Void) {
|
|
||||||
menu.removeAllItems()
|
|
||||||
onSelectCallback = onSelect
|
|
||||||
|
|
||||||
for item in items {
|
|
||||||
let menuItem = NSMenuItem(
|
|
||||||
title: item.name,
|
|
||||||
action: #selector(menuItemSelected(_:)),
|
|
||||||
keyEquivalent: ""
|
|
||||||
)
|
)
|
||||||
menuItem.target = self
|
.foregroundStyle(isFocused ? .black : .primary)
|
||||||
menuItem.tag = Int(item.id)
|
.animation(.easeInOut(duration: 0.15), value: isFocused)
|
||||||
menuItem.image = NSImage(systemSymbolName: item.type.iconName, accessibilityDescription: nil)
|
|
||||||
menu.addItem(menuItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func menuItemSelected(_ sender: NSMenuItem) {
|
|
||||||
onSelectCallback?(Int64(sender.tag))
|
|
||||||
}
|
|
||||||
|
|
||||||
func showMenu(in view: NSView) {
|
|
||||||
let location = NSPoint(x: 0, y: view.bounds.height + 4)
|
|
||||||
menu.popUp(positioning: nil, at: location, in: view)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - ProfileType Extension
|
|
||||||
|
|
||||||
private extension ProfileType {
|
|
||||||
var iconName: String {
|
|
||||||
switch self {
|
|
||||||
case .local:
|
|
||||||
"doc.fill"
|
|
||||||
case .icloud:
|
|
||||||
"icloud.fill"
|
|
||||||
case .remote:
|
|
||||||
"cloud.fill"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - View Extension
|
// MARK: - View Extension
|
||||||
|
|
||||||
private extension View {
|
private extension View {
|
||||||
|
|||||||
@@ -11,7 +11,12 @@
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@StateObject private var viewModel = ImportProfileViewModel()
|
@StateObject private var viewModel = ImportProfileViewModel()
|
||||||
|
|
||||||
public init() {}
|
var onComplete: (() -> Void)?
|
||||||
|
|
||||||
|
public init(onComplete: (() -> Void)? = nil) {
|
||||||
|
self.onComplete = onComplete
|
||||||
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
VStack(alignment: .center) {
|
VStack(alignment: .center) {
|
||||||
if !viewModel.selected {
|
if !viewModel.selected {
|
||||||
@@ -27,7 +32,7 @@
|
|||||||
{ endpoint in
|
{ endpoint in
|
||||||
viewModel.selected = true
|
viewModel.selected = true
|
||||||
Task {
|
Task {
|
||||||
await viewModel.handleEndpoint(endpoint, environments: environments, dismiss: dismiss)
|
await viewModel.handleEndpoint(endpoint, environments: environments)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("Select Device")
|
Text("Select Device")
|
||||||
@@ -61,6 +66,12 @@
|
|||||||
.focusSection()
|
.focusSection()
|
||||||
.alert($viewModel.alert)
|
.alert($viewModel.alert)
|
||||||
.navigationTitle("Import Profile")
|
.navigationTitle("Import Profile")
|
||||||
|
.onChange(of: viewModel.importSucceeded) { newValue in
|
||||||
|
if newValue {
|
||||||
|
onComplete?()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
@Published public var socket: NWSocket?
|
@Published public var socket: NWSocket?
|
||||||
@Published public var profiles: [LibboxProfilePreview]?
|
@Published public var profiles: [LibboxProfilePreview]?
|
||||||
@Published public var isImporting = false
|
@Published public var isImporting = false
|
||||||
|
@Published public var importSucceeded = false
|
||||||
|
|
||||||
public func reset() {
|
public func reset() {
|
||||||
if let connection {
|
if let connection {
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
profiles = nil
|
profiles = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public func handleEndpoint(_ endpoint: NWEndpoint, environments: ExtensionEnvironments, dismiss: DismissAction) async {
|
public func handleEndpoint(_ endpoint: NWEndpoint, environments: ExtensionEnvironments) async {
|
||||||
let connection = NWConnection(to: endpoint, using: NWParameters.applicationService)
|
let connection = NWConnection(to: endpoint, using: NWParameters.applicationService)
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
socket = NWSocket(connection)
|
socket = NWSocket(connection)
|
||||||
@@ -44,14 +45,14 @@
|
|||||||
}
|
}
|
||||||
connection.start(queue: .global())
|
connection.start(queue: .global())
|
||||||
do {
|
do {
|
||||||
try await loopMessages(environments: environments, dismiss: dismiss)
|
try await loopMessages(environments: environments)
|
||||||
} catch {
|
} catch {
|
||||||
alert = AlertState(error: error)
|
alert = AlertState(error: error)
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated func loopMessages(environments: ExtensionEnvironments, dismiss: DismissAction) async throws {
|
private nonisolated func loopMessages(environments: ExtensionEnvironments) async throws {
|
||||||
guard let socket = await socket else {
|
guard let socket = await socket else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@
|
|||||||
if let error {
|
if let error {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
try await importProfile(content!, environments: environments, dismiss: dismiss)
|
try await importProfile(content!, environments: environments)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
throw NSError(domain: "unknown message type \(message[0])", code: 0)
|
throw NSError(domain: "unknown message type \(message[0])", code: 0)
|
||||||
@@ -120,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated func importProfile(_ content: LibboxProfileContent, environments: ExtensionEnvironments, dismiss: DismissAction) async throws {
|
private nonisolated func importProfile(_ content: LibboxProfileContent, environments: ExtensionEnvironments) async throws {
|
||||||
var type: ProfileType = .local
|
var type: ProfileType = .local
|
||||||
switch content.type {
|
switch content.type {
|
||||||
case LibboxProfileTypeLocal:
|
case LibboxProfileTypeLocal:
|
||||||
@@ -141,11 +142,12 @@
|
|||||||
if content.lastUpdated > 0 {
|
if content.lastUpdated > 0 {
|
||||||
lastUpdated = Date(timeIntervalSince1970: Double(content.lastUpdated))
|
lastUpdated = Date(timeIntervalSince1970: Double(content.lastUpdated))
|
||||||
}
|
}
|
||||||
try await ProfileManager.create(Profile(name: content.name, type: type, path: profileConfig.relativePath, remoteURL: content.remotePath, autoUpdate: content.autoUpdate, lastUpdated: lastUpdated))
|
let uniqueProfileName = try await ProfileManager.uniqueName(content.name)
|
||||||
|
try await ProfileManager.create(Profile(name: uniqueProfileName, type: type, path: profileConfig.relativePath, remoteURL: content.remotePath, autoUpdate: content.autoUpdate, lastUpdated: lastUpdated))
|
||||||
await reset()
|
await reset()
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
environments.profileUpdate.send()
|
environments.profileUpdate.send()
|
||||||
dismiss()
|
importSucceeded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import Foundation
|
||||||
|
import Libbox
|
||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct NewProfileMenuView: View {
|
||||||
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var alert: AlertState?
|
||||||
|
@State private var showFileImporter = false
|
||||||
|
@State private var importRequest: NewProfileView.ImportRequest?
|
||||||
|
@State private var localImportRequest: NewProfileView.LocalImportRequest?
|
||||||
|
#if os(iOS)
|
||||||
|
@State private var showQRScanner = false
|
||||||
|
#elseif os(tvOS)
|
||||||
|
@State private var importCompleted = false
|
||||||
|
#elseif os(macOS)
|
||||||
|
@State private var showNewProfile = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
macOSBody
|
||||||
|
#else
|
||||||
|
otherBody
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private var macOSBody: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("New Profile")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
|
menuContent
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Divider()
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(NSColor.controlBackgroundColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert($alert)
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: $showFileImporter,
|
||||||
|
allowedContentTypes: [.profile, .json],
|
||||||
|
allowsMultipleSelection: false
|
||||||
|
) { result in
|
||||||
|
handleFileImport(result)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showNewProfile) {
|
||||||
|
NewProfileView(onSuccess: { _ in
|
||||||
|
dismiss()
|
||||||
|
})
|
||||||
|
.environmentObject(environments)
|
||||||
|
}
|
||||||
|
.sheet(item: $localImportRequest) { request in
|
||||||
|
NewProfileView(localImportRequest: request, onSuccess: { _ in
|
||||||
|
dismiss()
|
||||||
|
})
|
||||||
|
.environmentObject(environments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private var otherBody: some View {
|
||||||
|
Group {
|
||||||
|
if let request = importRequest {
|
||||||
|
NewProfileView(request)
|
||||||
|
.environmentObject(environments)
|
||||||
|
} else if let request = localImportRequest {
|
||||||
|
NewProfileView(localImportRequest: request)
|
||||||
|
.environmentObject(environments)
|
||||||
|
} else {
|
||||||
|
menuContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Profile")
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.alert($alert)
|
||||||
|
#if os(tvOS)
|
||||||
|
.onChange(of: importCompleted) { newValue in
|
||||||
|
if newValue {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: $showFileImporter,
|
||||||
|
allowedContentTypes: [.profile, .json],
|
||||||
|
allowsMultipleSelection: false
|
||||||
|
) { result in
|
||||||
|
handleFileImport(result)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#if os(iOS)
|
||||||
|
.sheet(isPresented: $showQRScanner) {
|
||||||
|
QRCodeScannerView { remoteProfile in
|
||||||
|
importRequest = NewProfileView.ImportRequest(name: remoteProfile.name, url: remoteProfile.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var menuContent: some View {
|
||||||
|
FormView {
|
||||||
|
Section {
|
||||||
|
#if os(tvOS)
|
||||||
|
FormNavigationLink {
|
||||||
|
ImportProfileView(onComplete: {
|
||||||
|
importCompleted = true
|
||||||
|
})
|
||||||
|
.environmentObject(environments)
|
||||||
|
} label: {
|
||||||
|
Label("Import from iPhone or iPad", systemImage: "iphone.and.arrow.forward")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
FormButton {
|
||||||
|
showFileImporter = true
|
||||||
|
} label: {
|
||||||
|
Label("Import from File", systemImage: "doc.badge.plus")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
FormButton {
|
||||||
|
showQRScanner = true
|
||||||
|
} label: {
|
||||||
|
Label("Scan QR Code", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
FormButton {
|
||||||
|
showNewProfile = true
|
||||||
|
} label: {
|
||||||
|
Label("Create Manually", systemImage: "square.and.pencil")
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
FormNavigationLink {
|
||||||
|
NewProfileView()
|
||||||
|
.environmentObject(environments)
|
||||||
|
} label: {
|
||||||
|
Label("Create Manually", systemImage: "square.and.pencil")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
private func handleFileImport(_ result: Result<[URL], Error>) {
|
||||||
|
do {
|
||||||
|
let urls = try result.get()
|
||||||
|
guard let url = urls.first else { return }
|
||||||
|
|
||||||
|
if url.pathExtension.lowercased() == "json" {
|
||||||
|
let fileName = url.deletingPathExtension().lastPathComponent
|
||||||
|
localImportRequest = NewProfileView.LocalImportRequest(name: fileName, fileURL: url)
|
||||||
|
} else {
|
||||||
|
_ = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { url.stopAccessingSecurityScopedResource() }
|
||||||
|
|
||||||
|
let content = try LibboxProfileContent.from(Data(contentsOf: url))
|
||||||
|
|
||||||
|
alert = AlertState(
|
||||||
|
title: String(localized: "Import Profile"),
|
||||||
|
message: String(localized: "Are you sure to import profile \(content.name)?"),
|
||||||
|
primaryButton: .default(String(localized: "Import")) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await content.importProfile()
|
||||||
|
environments.profileUpdate.send()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert = AlertState(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -16,11 +16,23 @@ public struct NewProfileView: View {
|
|||||||
public let url: String
|
public let url: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct LocalImportRequest: Hashable, Identifiable {
|
||||||
|
public var id: String { fileURL.absoluteString }
|
||||||
|
public let name: String
|
||||||
|
public let fileURL: URL
|
||||||
|
|
||||||
|
public init(name: String, fileURL: URL) {
|
||||||
|
self.name = name
|
||||||
|
self.fileURL = fileURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
_ importRequest: ImportRequest? = nil,
|
_ importRequest: ImportRequest? = nil,
|
||||||
|
localImportRequest: LocalImportRequest? = nil,
|
||||||
onSuccess: ((Profile) async -> Void)? = nil
|
onSuccess: ((Profile) async -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
_viewModel = StateObject(wrappedValue: NewProfileViewModel(importRequest: importRequest))
|
_viewModel = StateObject(wrappedValue: NewProfileViewModel(importRequest: importRequest, localImportRequest: localImportRequest))
|
||||||
self.onSuccess = onSuccess
|
self.onSuccess = onSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +120,7 @@ public struct NewProfileView: View {
|
|||||||
Task {
|
Task {
|
||||||
await viewModel.createProfile(
|
await viewModel.createProfile(
|
||||||
environments: environments,
|
environments: environments,
|
||||||
dismiss: onSuccess == nil ? dismiss : nil,
|
dismiss: dismiss,
|
||||||
onSuccess: onSuccess
|
onSuccess: onSuccess
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -149,7 +161,7 @@ public struct NewProfileView: View {
|
|||||||
Task {
|
Task {
|
||||||
await viewModel.createProfile(
|
await viewModel.createProfile(
|
||||||
environments: environments,
|
environments: environments,
|
||||||
dismiss: onSuccess == nil ? dismiss : nil,
|
dismiss: dismiss,
|
||||||
onSuccess: onSuccess
|
onSuccess: onSuccess
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,17 @@ public final class NewProfileViewModel: BaseViewModel {
|
|||||||
@Published public var autoUpdateInterval: Int32 = 60
|
@Published public var autoUpdateInterval: Int32 = 60
|
||||||
@Published public var pickerPresented = false
|
@Published public var pickerPresented = false
|
||||||
|
|
||||||
public init(importRequest: NewProfileView.ImportRequest? = nil) {
|
public init(importRequest: NewProfileView.ImportRequest? = nil, localImportRequest: NewProfileView.LocalImportRequest? = nil) {
|
||||||
super.init()
|
super.init()
|
||||||
if let importRequest {
|
if let importRequest {
|
||||||
profileName = importRequest.name
|
profileName = importRequest.name
|
||||||
profileType = .remote
|
profileType = .remote
|
||||||
remotePath = importRequest.url
|
remotePath = importRequest.url
|
||||||
|
} else if let localImportRequest {
|
||||||
|
profileName = localImportRequest.name
|
||||||
|
profileType = .local
|
||||||
|
fileImport = true
|
||||||
|
fileURL = localImportRequest.fileURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,12 +74,11 @@ public final class NewProfileViewModel: BaseViewModel {
|
|||||||
|
|
||||||
if let onSuccess {
|
if let onSuccess {
|
||||||
await onSuccess(createdProfile)
|
await onSuccess(createdProfile)
|
||||||
} else {
|
}
|
||||||
if sendUpdateNotification {
|
if sendUpdateNotification {
|
||||||
environments.profileUpdate.send()
|
environments.profileUpdate.send()
|
||||||
}
|
}
|
||||||
dismiss?()
|
dismiss?()
|
||||||
}
|
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
resetFields()
|
resetFields()
|
||||||
@@ -146,9 +150,11 @@ public final class NewProfileViewModel: BaseViewModel {
|
|||||||
lastUpdated = .now
|
lastUpdated = .now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let uniqueProfileName = try await ProfileManager.uniqueName(profileName)
|
||||||
|
|
||||||
// Create Profile object - GRDB will set its ID after insertion
|
// Create Profile object - GRDB will set its ID after insertion
|
||||||
let profile = Profile(
|
let profile = Profile(
|
||||||
name: profileName,
|
name: uniqueProfileName,
|
||||||
type: profileType,
|
type: profileType,
|
||||||
path: savePath,
|
path: savePath,
|
||||||
remoteURL: remoteURL,
|
remoteURL: remoteURL,
|
||||||
|
|||||||
@@ -44,53 +44,14 @@ public struct ProfileActionToolbar: View {
|
|||||||
Label("View Content", systemImage: "doc.fill")
|
Label("View Content", systemImage: "doc.fill")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
}
|
}
|
||||||
FormButton {
|
|
||||||
viewModel.isLoading = true
|
|
||||||
Task {
|
|
||||||
await viewModel.updateProfile(profile, environments: environments)
|
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
Label("Update", systemImage: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
}
|
|
||||||
FormButton(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash.fill")
|
|
||||||
}
|
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
private var tvOSBody: some View {
|
private var tvOSBody: some View {
|
||||||
Section("Action") {
|
EmptyView()
|
||||||
if profile.type == .remote {
|
|
||||||
FormButton {
|
|
||||||
viewModel.isLoading = true
|
|
||||||
Task {
|
|
||||||
await viewModel.updateProfile(profile, environments: environments)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Update", systemImage: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
}
|
|
||||||
FormButton(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash.fill")
|
|
||||||
}
|
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -108,24 +69,7 @@ public struct ProfileActionToolbar: View {
|
|||||||
Button("View Content") {
|
Button("View Content") {
|
||||||
openWindow(value: EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
openWindow(value: EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.isLoading = true
|
|
||||||
Task {
|
|
||||||
await viewModel.updateProfile(profile, environments: environments)
|
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
Text("Update")
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Delete", role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundColor(.red)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#if os(iOS)
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import CodeScanner
|
||||||
|
import Libbox
|
||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct QRCodeScannerView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var alert: AlertState?
|
||||||
|
|
||||||
|
private let onScan: (LibboxImportRemoteProfile) -> Void
|
||||||
|
|
||||||
|
public init(onScan: @escaping (LibboxImportRemoteProfile) -> Void) {
|
||||||
|
self.onScan = onScan
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
NavigationStackCompat {
|
||||||
|
CodeScannerView(codeTypes: [.qr], showViewfinder: true) { response in
|
||||||
|
handleScan(response)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.navigationTitle("Scan QR Code")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert($alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleScan(_ result: Result<ScanResult, ScanError>) {
|
||||||
|
switch result {
|
||||||
|
case let .success(scanResult):
|
||||||
|
var error: NSError?
|
||||||
|
let remoteProfile = LibboxParseRemoteProfileImportLink(scanResult.string, &error)
|
||||||
|
if let error {
|
||||||
|
alert = AlertState(title: String(localized: "Invalid QR Code"), message: error.localizedDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let remoteProfile else {
|
||||||
|
alert = AlertState(title: String(localized: "Invalid QR Code"), message: String(localized: "The QR code does not contain a valid profile import link."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
onScan(remoteProfile)
|
||||||
|
case let .failure(error):
|
||||||
|
switch error {
|
||||||
|
case .permissionDenied:
|
||||||
|
alert = AlertState(title: String(localized: "Camera Access Denied"), message: String(localized: "Please enable camera access in Settings to scan QR codes."))
|
||||||
|
default:
|
||||||
|
alert = AlertState(title: String(localized: "Scanner Error"), message: String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -53,7 +53,12 @@ public struct QRCodeSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(macOS)
|
||||||
|
NavigationSheet {
|
||||||
|
QRCodeContentView(profileName: profileName, remoteURL: remoteURL)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 400, minHeight: 400)
|
||||||
|
#elseif os(iOS) || os(tvOS)
|
||||||
if #available(iOS 16.0, tvOS 17.0, *) {
|
if #available(iOS 16.0, tvOS 17.0, *) {
|
||||||
NavigationStackCompat {
|
NavigationStackCompat {
|
||||||
QRCodeContentView(profileName: profileName, remoteURL: remoteURL)
|
QRCodeContentView(profileName: profileName, remoteURL: remoteURL)
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ public extension LibboxProfileContent {
|
|||||||
if lastUpdated > 0 {
|
if lastUpdated > 0 {
|
||||||
lastUpdatedAt = Date(timeIntervalSince1970: Double(lastUpdated))
|
lastUpdatedAt = Date(timeIntervalSince1970: Double(lastUpdated))
|
||||||
}
|
}
|
||||||
try await ProfileManager.create(Profile(name: name, type: ProfileType(rawValue: Int(type))!, path: profileConfig.relativePath, remoteURL: remotePath, autoUpdate: autoUpdate, autoUpdateInterval: autoUpdateInterval, lastUpdated: lastUpdatedAt))
|
let uniqueProfileName = try await ProfileManager.uniqueName(name)
|
||||||
|
try await ProfileManager.create(Profile(name: uniqueProfileName, type: ProfileType(rawValue: Int(type))!, path: profileConfig.relativePath, remoteURL: remotePath, autoUpdate: autoUpdate, autoUpdateInterval: autoUpdateInterval, lastUpdated: lastUpdatedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateShareFile() throws -> URL {
|
func generateShareFile() throws -> URL {
|
||||||
|
|||||||
@@ -95,4 +95,20 @@ public enum ProfileManager {
|
|||||||
try UInt32(Profile.fetchCount(db))
|
try UInt32(Profile.fetchCount(db))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public nonisolated static func uniqueName(_ baseName: String) async throws -> String {
|
||||||
|
let profiles = try await list()
|
||||||
|
let existingNames = Set(profiles.map(\.name))
|
||||||
|
if !existingNames.contains(baseName) {
|
||||||
|
return baseName
|
||||||
|
}
|
||||||
|
var counter = 1
|
||||||
|
while true {
|
||||||
|
let candidate = "\(baseName) (\(counter))"
|
||||||
|
if !existingNames.contains(candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
counter += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-1
@@ -196,6 +196,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Camera Access Denied" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "相机访问被拒绝"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Cancel" : {
|
"Cancel" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -377,6 +387,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Create Manually" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "手动创建"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Create New" : {
|
"Create New" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -588,6 +608,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Edit" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "编辑"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Edit Content" : {
|
"Edit Content" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -901,6 +931,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Import from File" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "从文件导入"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Import from iPhone or iPad" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "从 iPhone 或 iPad 导入"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Import Profile" : {
|
"Import Profile" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -974,6 +1024,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Invalid QR Code" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "无效的二维码"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"IP Version" : {
|
"IP Version" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -1069,6 +1129,7 @@
|
|||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
"Manage profiles" : {
|
"Manage profiles" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1324,6 +1385,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Please enable camera access in Settings to scan QR codes." : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "请在设置中启用相机访问权限以扫描二维码。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Please grant the permission for **SFMExtension**, then we can continue." : {
|
"Please grant the permission for **SFMExtension**, then we can continue." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -1355,7 +1426,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Profiles" : {
|
"Profiles" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1469,6 +1539,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Scan QR Code" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "扫描二维码"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Scanner Error" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "扫描器错误"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Search" : {
|
"Search" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -1489,6 +1579,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Select Profile" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "选择配置"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Service Error" : {
|
"Service Error" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -1539,6 +1639,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Share Content JSON File" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "分享内容 JSON 文件"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Share File" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "分享文件"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Share profile" : {
|
"Share profile" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
@@ -1715,6 +1835,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"The QR code does not contain a valid profile import link." : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "该二维码不包含有效的配置导入链接。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"This app needs to be placed under the Applications folder to work." : {
|
"This app needs to be placed under the Applications folder to work." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public struct MainView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Others", systemImage: "line.3.horizontal.circle")
|
Label("Others", systemImage: "line.3.horizontal.circle")
|
||||||
}
|
}
|
||||||
|
.menuIndicator(.hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,5 +122,7 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>As a universal proxy platform, sing-box configures routing according to your configuration.</string>
|
<string>As a universal proxy platform, sing-box configures routing according to your configuration.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Camera access is required to scan QR codes for importing profiles.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
3A017F922A4AB2E4009149FA /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 3A017F912A4AB2E4009149FA /* GRDB */; };
|
3A017F922A4AB2E4009149FA /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 3A017F912A4AB2E4009149FA /* GRDB */; };
|
||||||
3A096F8F2A4ED3DE00D4A2ED /* Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3A096F862A4ED3DE00D4A2ED /* Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
3A096F8F2A4ED3DE00D4A2ED /* Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3A096F862A4ED3DE00D4A2ED /* Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
3A1E8E692EDEC1AF00ADD104 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 3A1E8E682EDEC1AF00ADD104 /* CodeScanner */; };
|
||||||
3A2E87F22ED5A91100644195 /* Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87F12ED5A91100644195 /* Runestone */; };
|
3A2E87F22ED5A91100644195 /* Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87F12ED5A91100644195 /* Runestone */; };
|
||||||
3A2E87FB2ED5ABDA00644195 /* TreeSitterJSON5Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87FA2ED5ABDA00644195 /* TreeSitterJSON5Runestone */; };
|
3A2E87FB2ED5ABDA00644195 /* TreeSitterJSON5Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87FA2ED5ABDA00644195 /* TreeSitterJSON5Runestone */; };
|
||||||
3A3AA7FC2A4EFDAE002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
3A3AA7FC2A4EFDAE002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
||||||
@@ -538,6 +539,7 @@
|
|||||||
files = (
|
files = (
|
||||||
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */,
|
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */,
|
||||||
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */,
|
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */,
|
||||||
|
3A1E8E692EDEC1AF00ADD104 /* CodeScanner in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -760,6 +762,7 @@
|
|||||||
name = ApplicationLibrary;
|
name = ApplicationLibrary;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
3A4A020C2B53E3DC004EFB87 /* QRCode */,
|
3A4A020C2B53E3DC004EFB87 /* QRCode */,
|
||||||
|
3A1E8E682EDEC1AF00ADD104 /* CodeScanner */,
|
||||||
);
|
);
|
||||||
productName = ApplicationLibrary;
|
productName = ApplicationLibrary;
|
||||||
productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */;
|
productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */;
|
||||||
@@ -1070,6 +1073,7 @@
|
|||||||
3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */,
|
3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */,
|
||||||
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */,
|
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */,
|
||||||
3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
|
3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
|
||||||
|
3A1E8E672EDEC1AF00ADD104 /* XCRemoteSwiftPackageReference "CodeScanner" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 3AEC20C72A45991900A63465 /* Products */;
|
productRefGroup = 3AEC20C72A45991900A63465 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -2585,6 +2589,14 @@
|
|||||||
minimumVersion = 6.15.1;
|
minimumVersion = 6.15.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
3A1E8E672EDEC1AF00ADD104 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/twostraws/CodeScanner.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 2.5.2;
|
||||||
|
};
|
||||||
|
};
|
||||||
3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */ = {
|
3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/dagronf/qrcode.git";
|
repositoryURL = "https://github.com/dagronf/qrcode.git";
|
||||||
@@ -2625,6 +2637,11 @@
|
|||||||
package = 3A017F902A4AB2E4009149FA /* XCRemoteSwiftPackageReference "GRDB" */;
|
package = 3A017F902A4AB2E4009149FA /* XCRemoteSwiftPackageReference "GRDB" */;
|
||||||
productName = GRDB;
|
productName = GRDB;
|
||||||
};
|
};
|
||||||
|
3A1E8E682EDEC1AF00ADD104 /* CodeScanner */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 3A1E8E672EDEC1AF00ADD104 /* XCRemoteSwiftPackageReference "CodeScanner" */;
|
||||||
|
productName = CodeScanner;
|
||||||
|
};
|
||||||
3A2E87F12ED5A91100644195 /* Runestone */ = {
|
3A2E87F12ED5A91100644195 /* Runestone */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */;
|
package = 3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "3ae592e476adf467b19c2cde1d4ba44683fb6f05a27fc1959105b8eb14cce1d2",
|
"originHash" : "667da5f817ecc784ba63674676b2d92fd0c6e2af5a43e97251ec53e32dafbc26",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "binarycodable",
|
"identity" : "binarycodable",
|
||||||
@@ -46,6 +46,15 @@
|
|||||||
"version" : "0.12.1"
|
"version" : "0.12.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "codescanner",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/twostraws/CodeScanner.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5e886430238944c7200fc9e10dbf2d9550dba865",
|
||||||
|
"version" : "2.5.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "grdb.swift",
|
"identity" : "grdb.swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
Reference in New Issue
Block a user