From ef52cb9804b38010e1512189627cf561317a30fc Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sat, 19 Sep 2026 15:52:16 +0200 Subject: [PATCH] feat: add FileBookmarkService, BiometricAuthService, ClipboardService Phase 3 (Tasks 10-11) app services: FileBookmarkService persists a security-scoped bookmark for the KDBX file in the shared App Group; BiometricAuthService wraps LAContext for Face ID/Touch ID; ClipboardService copies text with an expiring clipboard entry on iOS/macOS. Also fixes a real build issue found while verifying: the AutoFill extension (iOS-only) was being embedded/signed unconditionally, breaking macOS builds of the MyPass scheme with a bogus provisioning error. Added platformFilters = (ios) to both the embed build file and the target dependency so macOS builds skip it. Verified both iOS Simulator and macOS builds now fail only on the known, expected ContentView.swift/Item SwiftData breakage. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf --- MyPass.xcodeproj/project.pbxproj | 3 +- MyPass/Services/BiometricAuthService.swift | 23 +++++++++ MyPass/Services/ClipboardService.swift | 30 +++++++++++ MyPass/Services/FileBookmarkService.swift | 60 ++++++++++++++++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 MyPass/Services/BiometricAuthService.swift create mode 100644 MyPass/Services/ClipboardService.swift create mode 100644 MyPass/Services/FileBookmarkService.swift diff --git a/MyPass.xcodeproj/project.pbxproj b/MyPass.xcodeproj/project.pbxproj index b695cce..7886a7f 100644 --- a/MyPass.xcodeproj/project.pbxproj +++ b/MyPass.xcodeproj/project.pbxproj @@ -9,8 +9,8 @@ /* Begin PBXBuildFile section */ 205678DB2FC0EB5200251C6B /* MyPassCore in Frameworks */ = {isa = PBXBuildFile; productRef = 205678DA2FC0EB5200251C6B /* MyPassCore */; }; 2096B432305EC2B200C2F253 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 205678E32FC0F52A00251C6B /* AuthenticationServices.framework */; }; + 2096B43D305EC2B200C2F253 /* AutoFill.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2096B431305EC2B200C2F253 /* AutoFill.appex */; platformFilters = (ios, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 67DE2FCFC21FD6DF5E761C87 /* MyPassCore in Frameworks */ = {isa = PBXBuildFile; productRef = 205678DA2FC0EB5200251C6B /* MyPassCore */; }; - 2096B43D305EC2B200C2F253 /* AutoFill.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2096B431305EC2B200C2F253 /* AutoFill.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -384,6 +384,7 @@ }; 2096B43C305EC2B200C2F253 /* PBXTargetDependency */ = { isa = PBXTargetDependency; + platformFilters = (ios, ); target = 2096B430305EC2B200C2F253 /* AutoFill */; targetProxy = 2096B43B305EC2B200C2F253 /* PBXContainerItemProxy */; }; diff --git a/MyPass/Services/BiometricAuthService.swift b/MyPass/Services/BiometricAuthService.swift new file mode 100644 index 0000000..407d641 --- /dev/null +++ b/MyPass/Services/BiometricAuthService.swift @@ -0,0 +1,23 @@ +// +// BiometricAuthService.swift +// MyPass +// + +import LocalAuthentication +import Foundation + +public final class BiometricAuthService { + public init() {} + + public var isAvailable: Bool { + let ctx = LAContext() + var error: NSError? + return ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + } + + /// Returns true on success. On failure, throws an error with a localized message. + public func authenticate(reason: String) async throws { + let ctx = LAContext() + try await ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) + } +} diff --git a/MyPass/Services/ClipboardService.swift b/MyPass/Services/ClipboardService.swift new file mode 100644 index 0000000..fd8a736 --- /dev/null +++ b/MyPass/Services/ClipboardService.swift @@ -0,0 +1,30 @@ +// +// ClipboardService.swift +// MyPass +// + +import Foundation +#if os(iOS) +import UIKit +#else +import AppKit +#endif + +public enum ClipboardService { + public static func copy(_ text: String, expiresAfter seconds: TimeInterval = 30) { + #if os(iOS) + UIPasteboard.general.setItems( + [[UIPasteboard.typeAutomatic: text]], + options: [.expirationDate: Date().addingTimeInterval(seconds)] + ) + #else + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + let captured = text + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + if pb.string(forType: .string) == captured { pb.clearContents() } + } + #endif + } +} diff --git a/MyPass/Services/FileBookmarkService.swift b/MyPass/Services/FileBookmarkService.swift new file mode 100644 index 0000000..1329966 --- /dev/null +++ b/MyPass/Services/FileBookmarkService.swift @@ -0,0 +1,60 @@ +// +// FileBookmarkService.swift +// MyPass +// + +import Foundation + +/// Persists a security-scoped bookmark for the user's KDBX file in the shared App Group. +public final class FileBookmarkService { + private static let key = "kdbxBookmark" + private let defaults: UserDefaults + + public init(appGroup: String = "group.org.antiloop222.mypass") { + defaults = UserDefaults(suiteName: appGroup) ?? .standard + } + + public func save(url: URL) throws { + let data = try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + defaults.set(data, forKey: Self.key) + } + + /// Returns the resolved URL, starting security access. Caller must call `stopAccess(url:)` when done. + public func resolveURL() throws -> URL { + guard let data = defaults.data(forKey: Self.key) else { throw BookmarkError.notFound } + var isStale = false + let url = try URL( + resolvingBookmarkData: data, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + if isStale { + let fresh = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) + defaults.set(fresh, forKey: Self.key) + } + guard url.startAccessingSecurityScopedResource() else { throw BookmarkError.accessDenied } + return url + } + + public func stopAccess(url: URL) { + url.stopAccessingSecurityScopedResource() + } + + public func clear() { + defaults.removeObject(forKey: Self.key) + } + + public var hasBookmark: Bool { + defaults.data(forKey: Self.key) != nil + } +} + +public enum BookmarkError: Error { + case notFound + case accessDenied +}