Refactor profile management
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user