diff --git a/DevLog/App/Delegate/AppleSignInDelegate.swift b/DevLog/App/Delegate/AppleSignInDelegate.swift index ef3a4756..cf861f1e 100644 --- a/DevLog/App/Delegate/AppleSignInDelegate.swift +++ b/DevLog/App/Delegate/AppleSignInDelegate.swift @@ -8,24 +8,25 @@ import Foundation import AuthenticationServices -class AppleSignInDelegate: NSObject, - ASAuthorizationControllerDelegate, - ASAuthorizationControllerPresentationContextProviding { - var continuation: CheckedContinuation - - init(continuation: CheckedContinuation) { - self.continuation = continuation +@MainActor +final class AppleSignInDelegate: NSObject, + ASAuthorizationControllerDelegate, + ASAuthorizationControllerPresentationContextProviding { + private let finish: @MainActor (Result) -> Void + + init(finish: @escaping @MainActor (Result) -> Void) { + self.finish = finish } - + func authorizationController( controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization ) { - continuation.resume(returning: authorization) + finish(.success(authorization)) } func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { - continuation.resume(throwing: error) + finish(.failure(error)) } func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { diff --git a/DevLog/Data/Repository/WebPageImageRepositoryImpl.swift b/DevLog/Data/Repository/WebPageImageRepositoryImpl.swift index df991373..31ebd8cf 100644 --- a/DevLog/Data/Repository/WebPageImageRepositoryImpl.swift +++ b/DevLog/Data/Repository/WebPageImageRepositoryImpl.swift @@ -13,16 +13,10 @@ final class WebPageImageRepositoryImpl: WebPageImageRepository { } func fetchDirSizeInBytes() async -> Int64 { - let store = self.store - return await Task.detached(priority: .utility) { - store.dirSizeInBytes() - }.value + await store.dirSizeInBytes() } func clearDirectory() async throws { - let store = self.store - try await Task.detached(priority: .utility) { - try store.clearDirectory() - }.value + try await store.clearDirectory() } } diff --git a/DevLog/Data/Repository/WebPageRepositoryImpl.swift b/DevLog/Data/Repository/WebPageRepositoryImpl.swift index 8b341707..214e336d 100644 --- a/DevLog/Data/Repository/WebPageRepositoryImpl.swift +++ b/DevLog/Data/Repository/WebPageRepositoryImpl.swift @@ -74,7 +74,7 @@ private extension WebPageRepositoryImpl { let expectedImageURL: URL do { - expectedImageURL = try metadataService.cachedImageURL(for: response.url) + expectedImageURL = try await metadataService.cachedImageURL(for: response.url) } catch { return true } diff --git a/DevLog/Infra/Common/InfraLayerError.swift b/DevLog/Infra/Common/InfraLayerError.swift index a11af19c..2a6af101 100644 --- a/DevLog/Infra/Common/InfraLayerError.swift +++ b/DevLog/Infra/Common/InfraLayerError.swift @@ -45,6 +45,7 @@ enum TokenError: Error { enum SocialLoginError: Error { case invalidOAuthState case failedToStartWebAuthenticationSession + case authenticationAlreadyInProgress } extension Error { diff --git a/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift b/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift index af4dccaa..2292baf6 100644 --- a/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift +++ b/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift @@ -22,6 +22,7 @@ final class AppleAuthenticationService: AuthenticationService { } private var appleSignInDelegate: AppleSignInDelegate? + private var appleSignInContinuation: CheckedContinuation? private let store = Firestore.firestore() private let functions = Functions.functions(region: "asia-northeast3") private let messaging = Messaging.messaging() @@ -191,6 +192,10 @@ final class AppleAuthenticationService: AuthenticationService { // Apple 인증 메서드 @MainActor func authenticateWithAppleAsync() async throws -> AppleAuthResponse { + guard appleSignInDelegate == nil, appleSignInContinuation == nil else { + throw SocialLoginError.authenticationAlreadyInProgress + } + // 자체 nonce 생성 및 해시화 let nonce = UUID().uuidString let hashedNonce = SHA256.hash(data: Data(nonce.utf8)).map { String(format: "%02x", $0) }.joined() @@ -203,7 +208,11 @@ final class AppleAuthenticationService: AuthenticationService { let controller = ASAuthorizationController(authorizationRequests: [request]) let authorization = try await withCheckedThrowingContinuation { continuation in - self.appleSignInDelegate = AppleSignInDelegate(continuation: continuation) + let delegate = AppleSignInDelegate { [weak self] result in + self?.completeAppleSignIn(with: result) + } + self.appleSignInDelegate = delegate + self.appleSignInContinuation = continuation controller.delegate = self.appleSignInDelegate controller.presentationContextProvider = self.appleSignInDelegate controller.performRequests() @@ -224,6 +233,21 @@ final class AppleAuthenticationService: AuthenticationService { idTokenString: idTokenString ) } + + @MainActor + private func completeAppleSignIn(with result: Result) { + guard let continuation = appleSignInContinuation else { return } + + appleSignInContinuation = nil + appleSignInDelegate = nil + + switch result { + case .success(let authorization): + continuation.resume(returning: authorization) + case .failure(let error): + continuation.resume(throwing: error) + } + } // Apple CustomToken 발급 메서드 private func requestAppleCustomToken(idToken: String, authorizationCode: Data) async throws -> String { diff --git a/DevLog/Infra/Service/WebPageMetadataService.swift b/DevLog/Infra/Service/WebPageMetadataService.swift index c7dcfa2a..8a88e827 100644 --- a/DevLog/Infra/Service/WebPageMetadataService.swift +++ b/DevLog/Infra/Service/WebPageMetadataService.swift @@ -51,7 +51,7 @@ final class WebPageMetadataService { } do { - let removed = try imageStore.removeImage(for: url) + let removed = try await imageStore.removeImage(for: url) if removed { logger.info("Removed cached image for URL: \(urlString)") @@ -61,20 +61,21 @@ final class WebPageMetadataService { } } - func cachedImageURL(for urlString: String) throws -> URL { + func cachedImageURL(for urlString: String) async throws -> URL { guard let url = URL(string: urlString) else { throw URLError(.badURL) } - return try imageStore.cachedImageURL(for: url) + return try await imageStore.cachedImageURL(for: url) } private func extractImageURL(from imageProvider: NSItemProvider?, url: URL) async throws -> URL? { guard let imageProvider else { return nil } - let imageStore = self.imageStore return try await withCheckedThrowingContinuation { continuation in - imageProvider.loadObject(ofClass: UIImage.self) { image, error in + // `[imageStore]`은 배열이 아니고 캡쳐 리스트 + // 명시적으로 imageStore을 캡쳐하겠다고 작성한 것 + imageProvider.loadObject(ofClass: UIImage.self) { [imageStore] image, error in if let error { continuation.resume(throwing: error) return @@ -86,11 +87,13 @@ final class WebPageMetadataService { return } - do { - let fileURL = try imageStore.saveImage(data, for: url) - continuation.resume(returning: fileURL) - } catch { - continuation.resume(throwing: error) + Task { + do { + let fileURL = try await imageStore.saveImage(data, for: url) + continuation.resume(returning: fileURL) + } catch { + continuation.resume(throwing: error) + } } } } diff --git a/DevLog/Storage/Persistence/WebPageImageStore.swift b/DevLog/Storage/Persistence/WebPageImageStore.swift index e89e2d90..b6367fcc 100644 --- a/DevLog/Storage/Persistence/WebPageImageStore.swift +++ b/DevLog/Storage/Persistence/WebPageImageStore.swift @@ -5,21 +5,54 @@ // Created by opfic on 4/14/26. // -import Combine import CryptoKit import Foundation -final class WebPageImageStore { - private let fileManager: FileManager - private let subject = CurrentValueSubject(0) +actor WebPageImageStore { + func cachedImageURL(for url: URL) async throws -> URL { + return try await Task.detached(priority: .utility) { + return try Self.cachedImageURL(for: url) + }.value + } + + func saveImage(_ data: Data, for url: URL) async throws -> URL { + return try await Task.detached(priority: .utility) { + return try Self.saveImage(data, for: url) + }.value + } + + func dirSizeInBytes() async -> Int64 { + do { + return try await Task.detached(priority: .utility) { + return try Self.dirSizeInBytes() + }.value + } catch { + return 0 + } + } + + func clearDirectory() async throws { + try await Task.detached(priority: .utility) { + try Self.clearDirectory() + }.value + } - init(fileManager: FileManager = .default) { - self.fileManager = fileManager - subject.send(dirSizeInBytes()) + func removeImage(for url: URL) async throws -> Bool { + return try await Task.detached(priority: .utility) { + return try Self.removeImage(for: url) + }.value } +} - func cachedImageURL(for url: URL) throws -> URL { - let imageDirectoryURL = try self.imageDirectoryURL(create: true) +private extension WebPageImageStore { + static func hashedFileName(for url: URL) -> String { + let hashValue = SHA256.hash(data: Data(url.absoluteString.utf8)) + return hashValue.map { String(format: "%02x", $0) }.joined() + } + + static func cachedImageURL(for url: URL) throws -> URL { + let fileManager = FileManager.default + let imageDirectoryURL = try imageDirectoryURL(create: true, fileManager: fileManager) let fileName = hashedFileName(for: url) return imageDirectoryURL @@ -27,42 +60,39 @@ final class WebPageImageStore { .appendingPathExtension("jpeg") } - func saveImage(_ data: Data, for url: URL) throws -> URL { + static func saveImage(_ data: Data, for url: URL) throws -> URL { let fileURL = try cachedImageURL(for: url) try data.write(to: fileURL, options: [.atomic]) - subject.send(dirSizeInBytes()) return fileURL } - func dirSizeInBytes() -> Int64 { - do { - let imageDirectoryURL = try self.imageDirectoryURL(create: false) - guard fileManager.fileExists(atPath: imageDirectoryURL.path) else { return 0 } - guard let enumerator = fileManager.enumerator( - at: imageDirectoryURL, - includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], - options: [.skipsHiddenFiles] - ) else { - return 0 - } + static func dirSizeInBytes() throws -> Int64 { + let fileManager = FileManager.default + let imageDirectoryURL = try imageDirectoryURL(create: false, fileManager: fileManager) + guard fileManager.fileExists(atPath: imageDirectoryURL.path) else { return 0 } + guard let enumerator = fileManager.enumerator( + at: imageDirectoryURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } - var total: Int64 = 0 - for case let fileURL as URL in enumerator { - guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), - resourceValues.isRegularFile == true, - let fileSize = resourceValues.fileSize else { - continue - } - total += Int64(fileSize) + var total: Int64 = 0 + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + resourceValues.isRegularFile == true, + let fileSize = resourceValues.fileSize else { + continue } - return total - } catch { - return 0 + total += Int64(fileSize) } + return total } - func clearDirectory() throws { - let imageDirectoryURL = try self.imageDirectoryURL(create: false) + static func clearDirectory() throws { + let fileManager = FileManager.default + let imageDirectoryURL = try imageDirectoryURL(create: false, fileManager: fileManager) guard fileManager.fileExists(atPath: imageDirectoryURL.path) else { return } let contentURLs = try fileManager.contentsOfDirectory( at: imageDirectoryURL, @@ -72,25 +102,17 @@ final class WebPageImageStore { for contentURL in contentURLs { try fileManager.removeItem(at: contentURL) } - subject.send(dirSizeInBytes()) } - func removeImage(for url: URL) throws -> Bool { + static func removeImage(for url: URL) throws -> Bool { + let fileManager = FileManager.default let fileURL = try cachedImageURL(for: url) guard fileManager.fileExists(atPath: fileURL.path) else { return false } try fileManager.removeItem(at: fileURL) - subject.send(dirSizeInBytes()) return true } -} - -private extension WebPageImageStore { - func hashedFileName(for url: URL) -> String { - let hashValue = SHA256.hash(data: Data(url.absoluteString.utf8)) - return hashValue.map { String(format: "%02x", $0) }.joined() - } - func imageDirectoryURL(create: Bool) throws -> URL { + static func imageDirectoryURL(create: Bool, fileManager: FileManager) throws -> URL { let directory = try fileManager.url( for: .applicationSupportDirectory, in: .userDomainMask,