Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions DevLog/App/Delegate/AppleSignInDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,25 @@
import Foundation
import AuthenticationServices

class AppleSignInDelegate: NSObject,
ASAuthorizationControllerDelegate,
ASAuthorizationControllerPresentationContextProviding {
var continuation: CheckedContinuation<ASAuthorization, Error>

init(continuation: CheckedContinuation<ASAuthorization, Error>) {
self.continuation = continuation
@MainActor
final class AppleSignInDelegate: NSObject,
ASAuthorizationControllerDelegate,
ASAuthorizationControllerPresentationContextProviding {
private let finish: @MainActor (Result<ASAuthorization, Error>) -> Void

init(finish: @escaping @MainActor (Result<ASAuthorization, Error>) -> 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 {
Expand Down
10 changes: 2 additions & 8 deletions DevLog/Data/Repository/WebPageImageRepositoryImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
2 changes: 1 addition & 1 deletion DevLog/Data/Repository/WebPageRepositoryImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions DevLog/Infra/Common/InfraLayerError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ enum TokenError: Error {
enum SocialLoginError: Error {
case invalidOAuthState
case failedToStartWebAuthenticationSession
case authenticationAlreadyInProgress
}

extension Error {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ final class AppleAuthenticationService: AuthenticationService {
}

private var appleSignInDelegate: AppleSignInDelegate?
private var appleSignInContinuation: CheckedContinuation<ASAuthorization, Error>?
private let store = Firestore.firestore()
private let functions = Functions.functions(region: "asia-northeast3")
private let messaging = Messaging.messaging()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -224,6 +233,21 @@ final class AppleAuthenticationService: AuthenticationService {
idTokenString: idTokenString
)
}

@MainActor
private func completeAppleSignIn(with result: Result<ASAuthorization, Error>) {
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 {
Expand Down
23 changes: 13 additions & 10 deletions DevLog/Infra/Service/WebPageMetadataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand All @@ -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
Expand All @@ -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)
}
}
}
}
Expand Down
114 changes: 68 additions & 46 deletions DevLog/Storage/Persistence/WebPageImageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,64 +5,94 @@
// Created by opfic on 4/14/26.
//

import Combine
import CryptoKit
import Foundation

final class WebPageImageStore {
private let fileManager: FileManager
private let subject = CurrentValueSubject<Int64, Never>(0)
actor WebPageImageStore {
Comment thread
opficdev marked this conversation as resolved.
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
.appendingPathComponent(fileName)
.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,
Expand All @@ -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,
Expand Down