diff --git a/MyPass/Services/FileBookmarkService.swift b/MyPass/Services/FileBookmarkService.swift index 1329966..c648a80 100644 --- a/MyPass/Services/FileBookmarkService.swift +++ b/MyPass/Services/FileBookmarkService.swift @@ -14,9 +14,27 @@ public final class FileBookmarkService { defaults = UserDefaults(suiteName: appGroup) ?? .standard } + // `.withSecurityScope` is a macOS-only bookmark-creation option; iOS grants scoped + // access automatically once the user picks a file, without that flag. + private static var creationOptions: URL.BookmarkCreationOptions { + #if os(macOS) + return .withSecurityScope + #else + return [] + #endif + } + + private static var resolutionOptions: URL.BookmarkResolutionOptions { + #if os(macOS) + return .withSecurityScope + #else + return [] + #endif + } + public func save(url: URL) throws { let data = try url.bookmarkData( - options: .withSecurityScope, + options: Self.creationOptions, includingResourceValuesForKeys: nil, relativeTo: nil ) @@ -29,12 +47,12 @@ public final class FileBookmarkService { var isStale = false let url = try URL( resolvingBookmarkData: data, - options: .withSecurityScope, + options: Self.resolutionOptions, relativeTo: nil, bookmarkDataIsStale: &isStale ) if isStale { - let fresh = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) + let fresh = try url.bookmarkData(options: Self.creationOptions, includingResourceValuesForKeys: nil, relativeTo: nil) defaults.set(fresh, forKey: Self.key) } guard url.startAccessingSecurityScopedResource() else { throw BookmarkError.accessDenied } diff --git a/MyPass/ViewModels/EntryEditViewModel.swift b/MyPass/ViewModels/EntryEditViewModel.swift new file mode 100644 index 0000000..0fe178e --- /dev/null +++ b/MyPass/ViewModels/EntryEditViewModel.swift @@ -0,0 +1,86 @@ +// +// EntryEditViewModel.swift +// MyPass +// + +import Foundation +import Combine +import SwiftUI +import MyPassCore + +@MainActor +final class EntryEditViewModel: ObservableObject { + @Published var title: String + @Published var username: String + @Published var password: String + @Published var url: String + @Published var notes: String + @Published var customFields: [CustomField] + @Published var errorMessage: String? + @Published var isSaving: Bool = false + + private let session: VaultSession + private let groupId: UUID? + private let existingEntry: Entry? + + /// `groupId` is required when adding a new entry, and unused when editing an existing one + /// (an existing entry's group is looked up internally by `VaultSession.updateEntry`). + init(session: VaultSession, groupId: UUID? = nil, existing: Entry? = nil) { + self.session = session + self.groupId = groupId + self.existingEntry = existing + title = existing?.title ?? "" + username = existing?.username ?? "" + password = existing?.password.reveal() ?? "" + url = existing?.url ?? "" + notes = existing?.notes ?? "" + customFields = existing?.customFields ?? [] + } + + var isEditing: Bool { existingEntry != nil } + + func save(dismiss: () -> Void) { + Task { + isSaving = true + errorMessage = nil + do { + if var entry = existingEntry { + entry.title = title + entry.username = username + entry.password = ProtectedString(password, isProtected: true) + entry.url = url + entry.notes = notes + entry.customFields = customFields + try session.updateEntry(entry) + } else { + guard let groupId else { + errorMessage = "No group selected for the new entry." + isSaving = false + return + } + let entry = Entry( + title: title, + username: username, + password: ProtectedString(password, isProtected: true), + url: url, + notes: notes, + customFields: customFields + ) + try session.addEntry(entry, toGroupId: groupId) + } + dismiss() + } catch { + errorMessage = error.localizedDescription + } + isSaving = false + } + } + + func addCustomField() { + customFields.append(CustomField(key: "", value: ProtectedString("", isProtected: false))) + } + + func removeCustomField(at offsets: IndexSet) { + customFields.remove(atOffsets: offsets) + } +} diff --git a/MyPass/Views/EntryDetailView.swift b/MyPass/Views/EntryDetailView.swift index c6a7d0e..c948f6f 100644 --- a/MyPass/Views/EntryDetailView.swift +++ b/MyPass/Views/EntryDetailView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import Combine import MyPassCore struct EntryDetailView: View { diff --git a/MyPass/Views/EntryEditView.swift b/MyPass/Views/EntryEditView.swift new file mode 100644 index 0000000..d6b1265 --- /dev/null +++ b/MyPass/Views/EntryEditView.swift @@ -0,0 +1,72 @@ +// +// EntryEditView.swift +// MyPass +// + +import SwiftUI +import MyPassCore + +struct EntryEditView: View { + @ObservedObject var vm: EntryEditViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("Credentials") { + TextField("Title", text: $vm.title) + TextField("Username", text: $vm.username) + .textContentType(.username) + .autocorrectionDisabled() + SecureField("Password", text: $vm.password) + .textContentType(.password) + TextField("URL", text: $vm.url) + .textContentType(.URL) + #if os(iOS) + .keyboardType(.URL) + #endif + .autocorrectionDisabled() + } + + Section("Notes") { + TextEditor(text: $vm.notes) + .frame(minHeight: 80) + } + + Section { + ForEach($vm.customFields) { $field in + HStack { + TextField("Key", text: $field.key) + Divider() + TextField("Value", text: Binding( + get: { field.value.reveal() }, + set: { field.value = ProtectedString($0, isProtected: field.value.isProtected) } + )) + } + } + .onDelete(perform: vm.removeCustomField) + Button("Add Field", action: vm.addCustomField) + } header: { + Text("Custom Fields") + } + + if let msg = vm.errorMessage { + Section { Text(msg).foregroundStyle(.red) } + } + } + .navigationTitle(vm.isEditing ? "Edit Entry" : "New Entry") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { vm.save { dismiss() } } + .disabled(vm.title.isEmpty || vm.isSaving) + } + } + } + } +} diff --git a/MyPass/Views/GroupBrowserView.swift b/MyPass/Views/GroupBrowserView.swift index 6948386..abc1e14 100644 --- a/MyPass/Views/GroupBrowserView.swift +++ b/MyPass/Views/GroupBrowserView.swift @@ -8,7 +8,7 @@ import MyPassCore struct GroupBrowserView: View { @ObservedObject var vm: VaultViewModel - let group: Group + let group: MyPassCore.Group @State private var selectedEntry: Entry? @State private var showAddEntry = false