From 727136f1872fd21113a1e95716e1cef3ba8c7867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 3 Dec 2025 11:15:56 +0800 Subject: [PATCH] Refactor profile management --- .../Views/Abstract/ViewModifiers.swift | 38 +- .../Views/Dashboard/ActiveDashboardView.swift | 96 +-- .../Dashboard/Cards/CardManagementSheet.swift | 68 +- .../Views/Dashboard/Cards/ProfileCard.swift | 610 +++++++-------- .../Dashboard/Cards/ProfilePickerSheet.swift | 699 ++++++++++++++++++ .../Cards/ProfileSelectorButton.swift | 314 ++------ .../Views/Dashboard/DashboardView.swift | 12 +- .../Views/Profile/ImportProfileView.swift | 15 +- .../Profile/ImportProfileViewModel.swift | 16 +- .../Views/Profile/NewProfileMenuView.swift | 206 ++++++ .../Views/Profile/NewProfileView.swift | 18 +- .../Views/Profile/NewProfileViewModel.swift | 20 +- .../Views/Profile/ProfileActionToolbar.swift | 58 +- .../Views/Profile/QRCodeScannerView.swift | 63 ++ .../Views/Profile/QRCodeSheet.swift | 7 +- Library/Database/Profile+Transferable.swift | 3 +- Library/Database/ProfileManager.swift | 16 + Localizable.xcstrings | 132 +++- MacLibrary/MainView.swift | 1 + SFI/Info.plist | 2 + sing-box.xcodeproj/project.pbxproj | 17 + .../xcshareddata/swiftpm/Package.resolved | 11 +- 22 files changed, 1635 insertions(+), 787 deletions(-) create mode 100644 ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift create mode 100644 ApplicationLibrary/Views/Profile/NewProfileMenuView.swift create mode 100644 ApplicationLibrary/Views/Profile/QRCodeScannerView.swift diff --git a/ApplicationLibrary/Views/Abstract/ViewModifiers.swift b/ApplicationLibrary/Views/Abstract/ViewModifiers.swift index 076b1c2..217bac9 100644 --- a/ApplicationLibrary/Views/Abstract/ViewModifiers.swift +++ b/ApplicationLibrary/Views/Abstract/ViewModifiers.swift @@ -28,31 +28,31 @@ public extension View { @ViewBuilder func actionButtonStyle() -> some View { #if os(tvOS) - ActionButtonWrapper { self } + ActionButtonWrapper { self } #else - if #available(iOS 26.0, macOS 26.0, *) { - self.frame(width: 36, height: 36) - .glassEffect(.regular.interactive(), in: .circle) - } else { - frame(width: 36, height: 36) - .background(Color.secondary.opacity(0.1)) - .clipShape(Circle()) - } + if #available(iOS 26.0, macOS 26.0, *) { + self.frame(width: 44, height: 32) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 8)) + } else { + frame(width: 44, height: 32) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } #endif } } #if os(tvOS) -private struct ActionButtonWrapper: View { - @Environment(\.isFocused) private var isFocused - let content: () -> Content + private struct ActionButtonWrapper: View { + @Environment(\.isFocused) private var isFocused + let content: () -> Content - var body: some View { - content() - .frame(width: 36, height: 36) - .background(isFocused ? Color.secondary.opacity(0.3) : Color.secondary.opacity(0.1)) - .clipShape(Circle()) - .focusEffectDisabled() + var body: some View { + content() + .frame(width: 70, height: 48) + .background(isFocused ? Color.secondary.opacity(0.3) : Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .focusEffectDisabled() + } } -} #endif diff --git a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift index 0b92382..579eab1 100644 --- a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift @@ -62,7 +62,7 @@ import SwiftUI .toolbar { toolbar } - #if os(tvOS) + #if os(tvOS) .navigationDestination(isPresented: $showGroups) { GroupListView() .navigationTitle("Groups") @@ -92,60 +92,60 @@ import SwiftUI } } } - #else + #else .sheet(isPresented: $showGroups) { - groupsSheetContent - }.sheet(isPresented: $showConnections) { - connectionsSheetContent - }.sheet(isPresented: $showCardManagement, onDismiss: { - cardConfigurationVersion += 1 - }, content: { - if #available(iOS 16.0, *) { - CardManagementSheet().presentationDetents([.large]).presentationDragIndicator(.visible) - } else { - CardManagementSheet() - } - }) - #endif + groupsSheetContent + }.sheet(isPresented: $showConnections) { + connectionsSheetContent + }.sheet(isPresented: $showCardManagement, onDismiss: { + cardConfigurationVersion += 1 + }, content: { + if #available(iOS 16.0, *) { + CardManagementSheet().presentationDetents([.large]).presentationDragIndicator(.visible) + } else { + CardManagementSheet() + } + }) + #endif #endif .onAppear { - if ApplicationLibrary.inPreview { - environments.commandClient.connect() - } else { + if ApplicationLibrary.inPreview { + environments.commandClient.connect() + } else { + environments.connect() + } + }.onChangeCompat(of: scenePhase) { phase in + guard phase == .active else { + return + } environments.connect() - } - }.onChangeCompat(of: scenePhase) { phase in - guard phase == .active else { - return - } - environments.connect() - }.onChangeCompat(of: profile.status) { status in - guard status.isConnected else { - return - } - environments.connect() - }.onReceive(environments.profileUpdate) { _ in - Task { - await coordinator.reload() - } - }.onReceive(environments.selectedProfileUpdate) { _ in - Task { - await coordinator.updateSelectedProfile() - if profile.status.isConnected { - await coordinator.reloadSystemProxy() + }.onChangeCompat(of: profile.status) { status in + guard status.isConnected else { + return + } + environments.connect() + }.onReceive(environments.profileUpdate) { _ in + Task { + await coordinator.reload() + } + }.onReceive(environments.selectedProfileUpdate) { _ in + Task { + await coordinator.updateSelectedProfile() + if profile.status.isConnected { + await coordinator.reloadSystemProxy() + } } } - } #if os(iOS) || os(tvOS) - .onReceive(environments.commandClient.$groups) { _ in - updateButtonVisibility() - }.onReceive(environments.commandClient.$connections) { _ in - updateButtonVisibility() - }.onReceive(profile.$status) { _ in - updateButtonVisibility() - }.onAppear { - updateButtonVisibility() - } + .onReceive(environments.commandClient.$groups) { _ in + updateButtonVisibility() + }.onReceive(environments.commandClient.$connections) { _ in + updateButtonVisibility() + }.onReceive(profile.$status) { _ in + updateButtonVisibility() + }.onAppear { + updateButtonVisibility() + } #endif } diff --git a/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift index 359fb1c..60cc11a 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift @@ -98,52 +98,52 @@ import SwiftUI } #if os(tvOS) -@MainActor public struct CardManagementView: View { - @StateObject private var configuration = DashboardCardConfiguration() - private let onDisappear: (() -> Void)? + @MainActor public struct CardManagementView: View { + @StateObject private var configuration = DashboardCardConfiguration() + private let onDisappear: (() -> Void)? - public init(onDisappear: (() -> Void)? = nil) { - self.onDisappear = onDisappear - } + public init(onDisappear: (() -> Void)? = nil) { + self.onDisappear = onDisappear + } - public var body: some View { - Group { - if configuration.isLoading { - ProgressView() - } else { - List { - ForEach(configuration.cardOrder) { card in - CardRow( - card: card, - isEnabled: configuration.isEnabled(card), - onToggle: { - configuration.toggleCard(card) + public var body: some View { + Group { + if configuration.isLoading { + ProgressView() + } else { + List { + ForEach(configuration.cardOrder) { card in + CardRow( + card: card, + isEnabled: configuration.isEnabled(card), + onToggle: { + configuration.toggleCard(card) + } + ) + } + .onMove { source, destination in + Task { + await configuration.moveCard(from: source, to: destination) } - ) + } } - .onMove { source, destination in + .applyContentMargins() + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Reset", role: .destructive) { Task { - await configuration.moveCard(from: source, to: destination) + await configuration.resetToDefault() } } } - .applyContentMargins() } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Reset", role: .destructive) { - Task { - await configuration.resetToDefault() - } - } + .onDisappear { + onDisappear?() } } - .onDisappear { - onDisappear?() - } } -} #endif private struct CardRow: View { diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift index 230f37f..6099d7c 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift @@ -3,6 +3,11 @@ import Libbox import Library import QRCode import SwiftUI +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif @MainActor public struct ProfileCard: View { @@ -33,64 +38,68 @@ public struct ProfileCard: View { } .disabled(viewModel.isUpdating) #if os(tvOS) - .navigationDestination(isPresented: $viewModel.showNewProfile) { - NewProfileContentView(onDisappear: { - environments.profileUpdate.send() - }) - .environmentObject(environments) - .toolbar { - ToolbarItemGroup(placement: .topBarLeading) { - BackButton() - } + .navigationDestination(isPresented: $viewModel.showNewProfile) { + NewProfileMenuView() + .environmentObject(environments) + .onDisappear { + environments.profileUpdate.send() + } + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } } - } - .navigationDestination(isPresented: $viewModel.showManageProfiles) { - ManageProfilesView() + .navigationDestination(isPresented: $viewModel.showProfilePicker) { + ProfilePickerSheet( + profileList: $profileList, + selectedProfileID: $selectedProfileID + ) .environmentObject(environments) - .navigationTitle("Manage profiles") + .navigationTitle("Profiles") .toolbar { ToolbarItemGroup(placement: .topBarLeading) { BackButton() } } - } - .navigationDestination(item: $viewModel.profileToEdit) { profile in - EditProfileView() - .environmentObject(profile) - .environmentObject(environments) - .toolbar { - ToolbarItemGroup(placement: .topBarLeading) { - BackButton() - } - } - } - .sheet(isPresented: $viewModel.showQRCode) { - if let profile = selectedProfile, let remoteURL = profile.remoteURL { - QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) } - } + .navigationDestination(item: $viewModel.profileToEdit) { profile in + EditProfileView() + .environmentObject(profile) + .environmentObject(environments) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + BackButton() + } + } + } + .sheet(isPresented: $viewModel.showQRCode) { + if let profile = selectedProfile, let remoteURL = profile.remoteURL { + QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) + } + } #else - .sheet(isPresented: $viewModel.showNewProfile, onDismiss: { - environments.profileUpdate.send() - }, content: { - NewProfileNavigationView() - .environmentObject(environments) - }) - .sheet(isPresented: $viewModel.showManageProfiles) { - manageProfilesSheet - } - .sheet(item: $viewModel.profileToEdit) { profile in - editProfileSheet(for: profile) - } - #if os(iOS) - .sheet(isPresented: $viewModel.showQRCode) { - if let profile = selectedProfile, let remoteURL = profile.remoteURL { - QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) - } - } + .sheet(isPresented: $viewModel.showNewProfile, onDismiss: { + environments.profileUpdate.send() + }, content: { + NewProfileNavigationView() + .environmentObject(environments) + }) + .sheet(isPresented: $viewModel.showProfilePicker) { + profilePickerSheet + } + .sheet(item: $viewModel.profileToEdit) { profile in + editProfileSheet(for: profile) + } + #if os(iOS) + .sheet(isPresented: $viewModel.showQRCode) { + if let profile = selectedProfile, let remoteURL = profile.remoteURL { + QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) + } + } + #endif #endif - #endif - .alert($viewModel.alert) + .alert($viewModel.alert) } private var headerView: some View { @@ -101,27 +110,14 @@ public struct ProfileCard: View { Spacer() - HStack(spacing: actionButtonSpacing) { - Button { - viewModel.showNewProfile = true - } label: { - Image(systemName: "plus") - .font(.system(size: 16)) - } - .buttonStyle(.plain) - .actionButtonStyle() - - if !profileList.isEmpty { - Button { - viewModel.showManageProfiles = true - } label: { - Image(systemName: "line.3.horizontal") - .font(.system(size: 16)) - } - .buttonStyle(.plain) - .actionButtonStyle() - } + Button { + viewModel.showNewProfile = true + } label: { + Image(systemName: "plus") + .font(.system(size: 16)) } + .buttonStyle(.plain) + .actionButtonStyle() } } @@ -133,9 +129,8 @@ public struct ProfileCard: View { .padding(.vertical, 8) } else { ProfileSelectorButton( - items: profileList, selectedItem: selectedProfile, - onSelect: { selectedProfileID = $0 } + isPickerPresented: $viewModel.showProfilePicker ) if let profile = selectedProfile { @@ -150,9 +145,9 @@ public struct ProfileCard: View { private var actionButtonSpacing: CGFloat { #if os(tvOS) - 24 + 24 #else - 12 + 12 #endif } @@ -163,17 +158,9 @@ public struct ProfileCard: View { if profile.type == .remote { updateButton(for: profile) - qrCodeButton(for: profile) } - #if !os(tvOS) - ProfileShareButton($viewModel.alert, profile.origin) { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 16)) - } - .buttonStyle(.plain) - .actionButtonStyle() - #endif + shareMenu(for: profile) } } @@ -240,6 +227,112 @@ public struct ProfileCard: View { #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 private func profileInfo(for profile: ProfilePreview) -> some View { HStack(spacing: 8) { @@ -265,48 +358,65 @@ public struct ProfileCard: View { } } - private var manageProfilesSheet: some View { - NavigationSheet( - title: String(localized: "Manage profiles"), - showDoneButton: true, - onDismiss: { viewModel.showManageProfiles = false }, - content: { - ManageProfilesView() + #if !os(tvOS) + private var profilePickerSheet: some View { + NavigationSheet( + title: String(localized: "Profiles"), + size: .large, + content: { + ProfilePickerSheet( + profileList: $profileList, + selectedProfileID: $selectedProfileID + ) .environmentObject(environments) - } - ) - } + } + ) + #if os(macOS) + .frame(minWidth: 400, minHeight: 300) + #endif + .modifier(OpaqueSheetBackground()) + } - @ViewBuilder - private func editProfileSheet(for profile: Profile) -> some View { - #if os(macOS) - NavigationSheet { - EditProfileView() - .environmentObject(profile) - .environmentObject(environments) - } - .frame(minWidth: 500, minHeight: 400) - #else - NavigationSheet(title: "Edit Profile") { - EditProfileView() - .environmentObject(profile) - .environmentObject(environments) - } - #endif - } + @ViewBuilder + private func editProfileSheet(for profile: Profile) -> some View { + #if os(macOS) + NavigationSheet { + EditProfileView() + .environmentObject(profile) + .environmentObject(environments) + } + .frame(minWidth: 500, minHeight: 400) + #else + NavigationSheet(title: "Edit Profile") { + EditProfileView() + .environmentObject(profile) + .environmentObject(environments) + } + #endif + } + #endif } // MARK: - ViewModel extension ProfileCard { + enum ShareItemType { + case file + case json + } + @MainActor class ViewModel: ObservableObject { @Published var showNewProfile = false - @Published var showManageProfiles = false + @Published var showProfilePicker = false @Published var showQRCode = false @Published var isUpdating = false @Published var alert: AlertState? @Published var profileToEdit: Profile? + @Published var shareItemType: ShareItemType? + #if os(macOS) + var shareButtonView: NSView? + #endif func updateProfile(_ profile: Profile, environments: ExtensionEnvironments) async { defer { isUpdating = false } @@ -330,253 +440,61 @@ extension ProfileCard { @MainActor struct NewProfileNavigationView: View { @EnvironmentObject private var environments: ExtensionEnvironments - @State private var createdProfile: Profile? var body: some View { #if os(macOS) - macOSBody - #else - iOSBody - #endif - } - - #if os(macOS) - @ViewBuilder - private var macOSBody: some View { - if let profile = createdProfile { - EditProfileView() - .environmentObject(profile) + NavigationSheet { + NewProfileMenuView() .environmentObject(environments) - } else { - NewProfileView { profile in - createdProfile = profile - } - .environmentObject(environments) } - } - #else - private var iOSBody: some View { + #else NavigationStackCompat { - if let profile = createdProfile { - EditProfileView() - .environmentObject(profile) - .environmentObject(environments) - } else { - NewProfileView { profile in - createdProfile = profile - } + NewProfileMenuView() .environmentObject(environments) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - } } .presentationDetentsIfAvailable() - } - #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 { - Group { - if let profile = createdProfile { - EditProfileView() - .environmentObject(profile) - .environmentObject(environments) - } else { - NewProfileView { profile in - createdProfile = profile - } - .environmentObject(environments) - } - } - .onDisappear { - onDisappear?() - } + #endif } } } + +// MARK: - OpaqueSheetBackground + +#if os(iOS) + private struct OpaqueSheetBackground: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16.4, *) { + content.presentationBackground(.regularMaterial) + } else { + content + } + } + } + +#elseif os(macOS) + private struct OpaqueSheetBackground: ViewModifier { + func body(content: Content) -> some View { + content + } + } + + 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) {} + } +#else + private struct OpaqueSheetBackground: ViewModifier { + func body(content: Content) -> some View { + content + } + } #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) { - viewModel.moveProfile(from: source, to: destination) - } - - private func deleteProfile(where profileIndex: IndexSet) { - viewModel.deleteProfile(where: profileIndex) - } - } - - @MainActor - struct ManageProfileItem: View { - @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) { - self.viewModel = viewModel - _profile = State(initialValue: profile) - } - - private var actionButtonSpacing: CGFloat { - #if os(tvOS) - 24 - #else - 8 - #endif - } - - 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 - } - - 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 - } - } -} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift new file mode 100644 index 0000000..b11fe35 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift @@ -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.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 diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfileSelectorButton.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfileSelectorButton.swift index 55cd193..f3733d4 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfileSelectorButton.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfileSelectorButton.swift @@ -1,274 +1,80 @@ import Library 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 { - let items: [ProfilePreview] let selectedItem: ProfilePreview? - let onSelect: (Int64) -> Void - - init( - items: [ProfilePreview], - selectedItem: ProfilePreview?, - onSelect: @escaping (Int64) -> Void - ) { - self.items = items - self.selectedItem = selectedItem - self.onSelect = onSelect - } + @Binding var isPickerPresented: Bool var body: some View { - #if os(iOS) || os(tvOS) - ProfileSelectorUIButton( - items: items, - selectedItem: selectedItem, - onSelect: onSelect - ) - .frame(height: 44) - .selectorBackground() + Button { + isPickerPresented = true + } label: { + HStack { + Text(selectedItem?.name ?? "Select Profile") + .font(.system(size: buttonFontSize, weight: .medium)) + .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() + #endif + } + + private var buttonHeight: CGFloat { + #if os(tvOS) + 60 #elseif os(macOS) - ProfileSelectorNSButton( - items: items, - selectedItem: selectedItem, - onSelect: onSelect - ) - .frame(height: 32) - .selectorBackground() + 32 + #else + 44 + #endif + } + + private var buttonFontSize: CGFloat { + #if os(macOS) + 13 + #else + 17 + #endif + } + + private var chevronSize: CGFloat { + #if os(macOS) + 10 + #else + 12 #endif } } -// MARK: - UIKit Implementation (iOS/tvOS) +#if os(tvOS) + private struct SelectorButtonStyle: ButtonStyle { + @Environment(\.isFocused) private var isFocused -#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) + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isFocused ? Color.white : Color.secondary.opacity(0.1)) + ) + .foregroundStyle(isFocused ? .black : .primary) + .animation(.easeInOut(duration: 0.15), value: isFocused) } } #endif -// MARK: - AppKit Implementation (macOS) - -#if os(macOS) - /// NSViewRepresentable wrapper for a clickable view with NSMenu - private struct ProfileSelectorNSButton: NSViewRepresentable { - let items: [ProfilePreview] - let selectedItem: ProfilePreview? - 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) { - guard let containerView = nsView as? MenuContainerView else { return } - updateContent(containerView) - context.coordinator.updateMenu(items: items, onSelect: onSelect) - } - - 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) { - super.init(frame: frameRect) - setupViews() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupViews() - } - - 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 - menuItem.tag = Int(item.id) - 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 - -// 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 private extension View { diff --git a/ApplicationLibrary/Views/Dashboard/DashboardView.swift b/ApplicationLibrary/Views/Dashboard/DashboardView.swift index 230646f..1ea7f02 100644 --- a/ApplicationLibrary/Views/Dashboard/DashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/DashboardView.swift @@ -35,7 +35,7 @@ public struct DashboardView: View { .onChangeCompat(of: importRemoteProfile.wrappedValue) { _ in handleImportRemoteProfile() } - #if os(tvOS) + #if os(tvOS) .navigationDestination(item: $importRemoteProfileRequest) { request in NewProfileView(request) .environmentObject(environments) @@ -48,16 +48,16 @@ public struct DashboardView: View { } } } - #else + #else .sheet(item: $importRemoteProfileRequest) { request in - importRemoteProfileSheet(for: request) - } - #endif + importRemoteProfileSheet(for: request) + } + #endif #if os(macOS) .onChangeCompat(of: controlActiveState) { state in guard state != .inactive, Variant.useSystemExtension, !coordinator.isLoading else { return } Task { await coordinator.reload() } - } + } #endif } diff --git a/ApplicationLibrary/Views/Profile/ImportProfileView.swift b/ApplicationLibrary/Views/Profile/ImportProfileView.swift index 442ff9e..48a8a76 100644 --- a/ApplicationLibrary/Views/Profile/ImportProfileView.swift +++ b/ApplicationLibrary/Views/Profile/ImportProfileView.swift @@ -11,7 +11,12 @@ @Environment(\.dismiss) private var dismiss @StateObject private var viewModel = ImportProfileViewModel() - public init() {} + var onComplete: (() -> Void)? + + public init(onComplete: (() -> Void)? = nil) { + self.onComplete = onComplete + } + public var body: some View { VStack(alignment: .center) { if !viewModel.selected { @@ -27,7 +32,7 @@ { endpoint in viewModel.selected = true Task { - await viewModel.handleEndpoint(endpoint, environments: environments, dismiss: dismiss) + await viewModel.handleEndpoint(endpoint, environments: environments) } } label: { Text("Select Device") @@ -61,6 +66,12 @@ .focusSection() .alert($viewModel.alert) .navigationTitle("Import Profile") + .onChange(of: viewModel.importSucceeded) { newValue in + if newValue { + onComplete?() + dismiss() + } + } } } diff --git a/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift b/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift index e363d35..292f2b2 100644 --- a/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift +++ b/ApplicationLibrary/Views/Profile/ImportProfileViewModel.swift @@ -13,6 +13,7 @@ @Published public var socket: NWSocket? @Published public var profiles: [LibboxProfilePreview]? @Published public var isImporting = false + @Published public var importSucceeded = false public func reset() { if let connection { @@ -28,7 +29,7 @@ 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) self.connection = connection socket = NWSocket(connection) @@ -44,14 +45,14 @@ } connection.start(queue: .global()) do { - try await loopMessages(environments: environments, dismiss: dismiss) + try await loopMessages(environments: environments) } catch { alert = AlertState(error: error) 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 { return } @@ -93,7 +94,7 @@ if let error { throw error } - try await importProfile(content!, environments: environments, dismiss: dismiss) + try await importProfile(content!, environments: environments) return default: 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 switch content.type { case LibboxProfileTypeLocal: @@ -141,11 +142,12 @@ if content.lastUpdated > 0 { 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 MainActor.run { environments.profileUpdate.send() - dismiss() + importSucceeded = true } } } diff --git a/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift b/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift new file mode 100644 index 0000000..39b553f --- /dev/null +++ b/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift @@ -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 +} diff --git a/ApplicationLibrary/Views/Profile/NewProfileView.swift b/ApplicationLibrary/Views/Profile/NewProfileView.swift index 5707d66..275424b 100644 --- a/ApplicationLibrary/Views/Profile/NewProfileView.swift +++ b/ApplicationLibrary/Views/Profile/NewProfileView.swift @@ -16,11 +16,23 @@ public struct NewProfileView: View { 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( _ importRequest: ImportRequest? = nil, + localImportRequest: LocalImportRequest? = nil, onSuccess: ((Profile) async -> Void)? = nil ) { - _viewModel = StateObject(wrappedValue: NewProfileViewModel(importRequest: importRequest)) + _viewModel = StateObject(wrappedValue: NewProfileViewModel(importRequest: importRequest, localImportRequest: localImportRequest)) self.onSuccess = onSuccess } @@ -108,7 +120,7 @@ public struct NewProfileView: View { Task { await viewModel.createProfile( environments: environments, - dismiss: onSuccess == nil ? dismiss : nil, + dismiss: dismiss, onSuccess: onSuccess ) } @@ -149,7 +161,7 @@ public struct NewProfileView: View { Task { await viewModel.createProfile( environments: environments, - dismiss: onSuccess == nil ? dismiss : nil, + dismiss: dismiss, onSuccess: onSuccess ) } diff --git a/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift b/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift index 053f98f..8383bfd 100644 --- a/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift +++ b/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift @@ -19,12 +19,17 @@ public final class NewProfileViewModel: BaseViewModel { @Published public var autoUpdateInterval: Int32 = 60 @Published public var pickerPresented = false - public init(importRequest: NewProfileView.ImportRequest? = nil) { + public init(importRequest: NewProfileView.ImportRequest? = nil, localImportRequest: NewProfileView.LocalImportRequest? = nil) { super.init() if let importRequest { profileName = importRequest.name profileType = .remote 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 { await onSuccess(createdProfile) - } else { - if sendUpdateNotification { - environments.profileUpdate.send() - } - dismiss?() } + if sendUpdateNotification { + environments.profileUpdate.send() + } + dismiss?() #if os(macOS) resetFields() @@ -146,9 +150,11 @@ public final class NewProfileViewModel: BaseViewModel { lastUpdated = .now } + let uniqueProfileName = try await ProfileManager.uniqueName(profileName) + // Create Profile object - GRDB will set its ID after insertion let profile = Profile( - name: profileName, + name: uniqueProfileName, type: profileType, path: savePath, remoteURL: remoteURL, diff --git a/ApplicationLibrary/Views/Profile/ProfileActionToolbar.swift b/ApplicationLibrary/Views/Profile/ProfileActionToolbar.swift index 9a038ce..b537329 100644 --- a/ApplicationLibrary/Views/Profile/ProfileActionToolbar.swift +++ b/ApplicationLibrary/Views/Profile/ProfileActionToolbar.swift @@ -44,53 +44,14 @@ public struct ProfileActionToolbar: View { Label("View Content", systemImage: "doc.fill") .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 #if os(tvOS) private var tvOSBody: some View { - Section("Action") { - 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) - } + EmptyView() } #endif @@ -108,25 +69,8 @@ public struct ProfileActionToolbar: View { Button("View Content") { 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() Button("Cancel") { diff --git a/ApplicationLibrary/Views/Profile/QRCodeScannerView.swift b/ApplicationLibrary/Views/Profile/QRCodeScannerView.swift new file mode 100644 index 0000000..e8b911a --- /dev/null +++ b/ApplicationLibrary/Views/Profile/QRCodeScannerView.swift @@ -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) { + 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 diff --git a/ApplicationLibrary/Views/Profile/QRCodeSheet.swift b/ApplicationLibrary/Views/Profile/QRCodeSheet.swift index 7a7bcc5..e33ebb7 100644 --- a/ApplicationLibrary/Views/Profile/QRCodeSheet.swift +++ b/ApplicationLibrary/Views/Profile/QRCodeSheet.swift @@ -53,7 +53,12 @@ public struct QRCodeSheet: 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, *) { NavigationStackCompat { QRCodeContentView(profileName: profileName, remoteURL: remoteURL) diff --git a/Library/Database/Profile+Transferable.swift b/Library/Database/Profile+Transferable.swift index a450bf7..5e63f36 100644 --- a/Library/Database/Profile+Transferable.swift +++ b/Library/Database/Profile+Transferable.swift @@ -52,7 +52,8 @@ public extension LibboxProfileContent { if lastUpdated > 0 { 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 { diff --git a/Library/Database/ProfileManager.swift b/Library/Database/ProfileManager.swift index 1032305..0a87c52 100644 --- a/Library/Database/ProfileManager.swift +++ b/Library/Database/ProfileManager.swift @@ -95,4 +95,20 @@ public enum ProfileManager { 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 + } + } } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 3e62eaa..af59df8 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -196,6 +196,16 @@ } } }, + "Camera Access Denied" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "相机访问被拒绝" + } + } + } + }, "Cancel" : { "localizations" : { "zh-Hans" : { @@ -377,6 +387,16 @@ } } }, + "Create Manually" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手动创建" + } + } + } + }, "Create New" : { "localizations" : { "zh-Hans" : { @@ -588,6 +608,16 @@ } } }, + "Edit" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑" + } + } + } + }, "Edit Content" : { "localizations" : { "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" : { "localizations" : { "zh-Hans" : { @@ -974,6 +1024,16 @@ } } }, + "Invalid QR Code" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无效的二维码" + } + } + } + }, "IP Version" : { "localizations" : { "zh-Hans" : { @@ -1069,6 +1129,7 @@ "shouldTranslate" : false }, "Manage profiles" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "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." : { "localizations" : { "zh-Hans" : { @@ -1355,7 +1426,6 @@ } }, "Profiles" : { - "extractionState" : "stale", "localizations" : { "zh-Hans" : { "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" : { "localizations" : { "zh-Hans" : { @@ -1489,6 +1579,16 @@ } } }, + "Select Profile" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择配置" + } + } + } + }, "Service Error" : { "localizations" : { "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" : { "localizations" : { "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." : { "localizations" : { "zh-Hans" : { diff --git a/MacLibrary/MainView.swift b/MacLibrary/MainView.swift index 9ef566c..1f086dc 100644 --- a/MacLibrary/MainView.swift +++ b/MacLibrary/MainView.swift @@ -48,6 +48,7 @@ public struct MainView: View { } label: { Label("Others", systemImage: "line.3.horizontal.circle") } + .menuIndicator(.hidden) } } } diff --git a/SFI/Info.plist b/SFI/Info.plist index b68a926..c043f02 100644 --- a/SFI/Info.plist +++ b/SFI/Info.plist @@ -122,5 +122,7 @@ NSLocalNetworkUsageDescription As a universal proxy platform, sing-box configures routing according to your configuration. + NSCameraUsageDescription + Camera access is required to scan QR codes for importing profiles. diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index aa14a41..77e536f 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 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, ); }; }; + 3A1E8E692EDEC1AF00ADD104 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 3A1E8E682EDEC1AF00ADD104 /* CodeScanner */; }; 3A2E87F22ED5A91100644195 /* Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87F12ED5A91100644195 /* Runestone */; }; 3A2E87FB2ED5ABDA00644195 /* TreeSitterJSON5Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87FA2ED5ABDA00644195 /* TreeSitterJSON5Runestone */; }; 3A3AA7FC2A4EFDAE002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; }; @@ -538,6 +539,7 @@ files = ( 3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */, 3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */, + 3A1E8E692EDEC1AF00ADD104 /* CodeScanner in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -760,6 +762,7 @@ name = ApplicationLibrary; packageProductDependencies = ( 3A4A020C2B53E3DC004EFB87 /* QRCode */, + 3A1E8E682EDEC1AF00ADD104 /* CodeScanner */, ); productName = ApplicationLibrary; productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; @@ -1070,6 +1073,7 @@ 3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */, 3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */, 3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 3A1E8E672EDEC1AF00ADD104 /* XCRemoteSwiftPackageReference "CodeScanner" */, ); productRefGroup = 3AEC20C72A45991900A63465 /* Products */; projectDirPath = ""; @@ -2585,6 +2589,14 @@ 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" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/dagronf/qrcode.git"; @@ -2625,6 +2637,11 @@ package = 3A017F902A4AB2E4009149FA /* XCRemoteSwiftPackageReference "GRDB" */; productName = GRDB; }; + 3A1E8E682EDEC1AF00ADD104 /* CodeScanner */ = { + isa = XCSwiftPackageProductDependency; + package = 3A1E8E672EDEC1AF00ADD104 /* XCRemoteSwiftPackageReference "CodeScanner" */; + productName = CodeScanner; + }; 3A2E87F12ED5A91100644195 /* Runestone */ = { isa = XCSwiftPackageProductDependency; package = 3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */; diff --git a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 73872e7..6e79221 100644 --- a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3ae592e476adf467b19c2cde1d4ba44683fb6f05a27fc1959105b8eb14cce1d2", + "originHash" : "667da5f817ecc784ba63674676b2d92fd0c6e2af5a43e97251ec53e32dafbc26", "pins" : [ { "identity" : "binarycodable", @@ -46,6 +46,15 @@ "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", "kind" : "remoteSourceControl",