Refactor profile management

This commit is contained in:
世界
2025-12-03 11:15:56 +08:00
parent adf4e5c75c
commit 727136f187
22 changed files with 1635 additions and 787 deletions
@@ -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<Content: View>: View {
@Environment(\.isFocused) private var isFocused
let content: () -> Content
private struct ActionButtonWrapper<Content: View>: 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
@@ -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
}
@@ -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 {
@@ -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
}
}
}
@@ -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 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 {
@@ -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
}
@@ -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()
}
}
}
}
@@ -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
}
}
}
@@ -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 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
)
}
@@ -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,
@@ -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") {
@@ -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 {
#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)
+2 -1
View File
@@ -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 {
+16
View File
@@ -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
}
}
}
+131 -1
View File
@@ -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" : {
+1
View File
@@ -48,6 +48,7 @@ public struct MainView: View {
} label: {
Label("Others", systemImage: "line.3.horizontal.circle")
}
.menuIndicator(.hidden)
}
}
}
+2
View File
@@ -122,5 +122,7 @@
</array>
<key>NSLocalNetworkUsageDescription</key>
<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>
</plist>
+17
View File
@@ -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" */;
@@ -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",