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
8 changes: 8 additions & 0 deletions Keychy/Keychy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@
4C004FB52F18D98C00D9063E /* ReviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C004FB42F18D98C00D9063E /* ReviewManager.swift */; };
4C004FBB2F19F1FE00D9063E /* RootViewModel+ReviewCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C004FBA2F19F1FE00D9063E /* RootViewModel+ReviewCheck.swift */; };
4C07024C2ECF10760026D6DC /* EffectSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C07024A2ECF10760026D6DC /* EffectSyncManager.swift */; };
4C25259C2F303745003CC5AD /* WidgetBundleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C25259B2F303745003CC5AD /* WidgetBundleModel.swift */; };
4C25259D2F3037CD003CC5AD /* WidgetBundleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C25259B2F303745003CC5AD /* WidgetBundleModel.swift */; };
4C25259E2F3037D6003CC5AD /* BundleImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6B5707E2EC206CD0049F969 /* BundleImageCache.swift */; };
4C3687F72EBFA87800C64E75 /* Pretendard-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4C3687F62EBFA87800C64E75 /* Pretendard-Medium.ttf */; };
4C3687FA2EBFC0FB00C64E75 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3687F82EBFC0FB00C64E75 /* NotificationManager.swift */; };
4C3687FC2EC05E6800C64E75 /* AccountAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3687FB2EC05E6800C64E75 /* AccountAlert.swift */; };
Expand Down Expand Up @@ -552,6 +555,7 @@
4C004FB42F18D98C00D9063E /* ReviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewManager.swift; sourceTree = "<group>"; };
4C004FBA2F19F1FE00D9063E /* RootViewModel+ReviewCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RootViewModel+ReviewCheck.swift"; sourceTree = "<group>"; };
4C07024A2ECF10760026D6DC /* EffectSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EffectSyncManager.swift; sourceTree = "<group>"; };
4C25259B2F303745003CC5AD /* WidgetBundleModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBundleModel.swift; sourceTree = "<group>"; };
4C3687F62EBFA87800C64E75 /* Pretendard-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pretendard-Medium.ttf"; sourceTree = "<group>"; };
4C3687F82EBFC0FB00C64E75 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
4C3687FB2EC05E6800C64E75 /* AccountAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountAlert.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1727,6 +1731,7 @@
isa = PBXGroup;
children = (
4CEC61D82EAE08C00099ECEE /* KeyringBundle.swift */,
4C25259B2F303745003CC5AD /* WidgetBundleModel.swift */,
AA9B2E882EB001AA0004D31C /* Background.swift */,
AA9B2E8A2EB001B70004D31C /* Carabiner.swift */,
AA0A54B62EC053E4007B5413 /* CarabinerType.swift */,
Expand Down Expand Up @@ -2678,6 +2683,7 @@
4C7775322EB0EEF100981C3E /* ItemDetailImage.swift in Sources */,
AA2146B12F15D43C0048D40E /* BundleEditView+Alert.swift in Sources */,
4C07024C2ECF10760026D6DC /* EffectSyncManager.swift in Sources */,
4C25259C2F303745003CC5AD /* WidgetBundleModel.swift in Sources */,
3828F54B2EC4D0C500F1B040 /* CollectionView+Handlers.swift in Sources */,
4CF2A96A2F0B94EA00BA9FDA /* View+PullToRefresh.swift in Sources */,
4C86A61F2F29E52D0023AA2D /* PurchaseHistoryView.swift in Sources */,
Expand Down Expand Up @@ -2779,7 +2785,9 @@
files = (
C6B56F702EC08ED40049F969 /* WidgetKeyring.swift in Sources */,
C6B56F612EC08CCE0049F969 /* KeyringImageCache.swift in Sources */,
4C25259E2F3037D6003CC5AD /* BundleImageCache.swift in Sources */,
4CC8D0192EF0395F00317467 /* AppIntent.swift in Sources */,
4C25259D2F3037CD003CC5AD /* WidgetBundleModel.swift in Sources */,
4CC8D01A2EF0395F00317467 /* WidgetConfiguration.mm in Sources */,
4CC8D01B2EF0395F00317467 /* WidgetKeychy.swift in Sources */,
4CC8D01C2EF0395F00317467 /* WidgetKeychyBundle.swift in Sources */,
Expand Down
32 changes: 32 additions & 0 deletions Keychy/Keychy/CommonModels/KeyringBundle/WidgetBundleModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// WidgetBundleModel.swift
// Keychy
//
// Created by 길지훈 on 2/2/26.
//

import Foundation

/// 위젯에서 사용할 뭉치 메타데이터
struct WidgetBundleModel: Codable, Identifiable, Hashable {
let id: String // Firestore documentId
let name: String // 뭉치 이름
let imagePath: String // App Group 내 이미지 경로
let createdAt: Date // 생성일 (위젯 목록 정렬용)

// 기존 데이터 호환용 (createdAt 없는 경우 .distantPast로 처리)
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
imagePath = try container.decode(String.self, forKey: .imagePath)
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? .distantPast
}

init(id: String, name: String, imagePath: String, createdAt: Date) {
self.id = id
self.name = name
self.imagePath = imagePath
self.createdAt = createdAt
}
}
196 changes: 153 additions & 43 deletions Keychy/Keychy/Core/Cache/BundleImageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,44 @@

import Foundation
import SwiftUI
import WidgetKit

/// 번들(MultiKeyring) 썸네일 이미지를 FileManager 기반으로 캐싱 (앱 샌드박스)
/// 번들(MultiKeyring) 썸네일 이미지를 FileManager 기반으로 캐싱 (App Group 사용)
class BundleImageCache {
static let shared = BundleImageCache()

// MARK: - 이미지 타입 정의
enum ImageType {
case full // 배경 포함 (앱용)
case widget // 배경 없음 (위젯용)

var suffix: String {
switch self {
case .full: return ""
case .widget: return "_widget"
}
}
}

private let fileManager = FileManager.default
private let appGroupIdentifier = "group.keychy.app"
private let metadataFileName = "available_bundles.json"
private let widgetKind = "WidgetKeychy"

/// App Group Container URL
private var containerURL: URL? {
fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
}

/// 캐시 디렉토리 경로 (앱 샌드박스)
/// 캐시 디렉토리 경로 (App Group)
private var cacheDirectory: URL {
let urls = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)
let bundleCache = urls[0].appendingPathComponent("BundleThumbnails", isDirectory: true)
guard let container = containerURL else {
// Fallback to local cache if App Group is not available
let urls = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)
return urls[0].appendingPathComponent("BundleThumbnails", isDirectory: true)
}

let bundleCache = container.appendingPathComponent("BundleThumbnails", isDirectory: true)

// 디렉토리가 없으면 생성
if !fileManager.fileExists(atPath: bundleCache.path) {
Expand All @@ -33,8 +59,8 @@ class BundleImageCache {
}

/// 메타데이터 파일 URL
private var metadataFileURL: URL {
cacheDirectory.appendingPathComponent(metadataFileName)
private var metadataFileURL: URL? {
containerURL?.appendingPathComponent(metadataFileName)
}

private init() {
Expand All @@ -44,12 +70,13 @@ class BundleImageCache {
// MARK: - 저장

/// PNG 데이터를 파일로 저장
func save(pngData: Data, for bundleID: String) {
let fileURL = cacheDirectory.appendingPathComponent("\(bundleID).png")
func save(pngData: Data, for bundleID: String, type: ImageType = .full) {
let fileName = "\(bundleID)\(type.suffix).png"
let fileURL = cacheDirectory.appendingPathComponent(fileName)

do {
try pngData.write(to: fileURL)

} catch {
print("❌ [BundleCache] 저장 실패: \(bundleID) - \(error.localizedDescription)")
}
Expand All @@ -58,8 +85,9 @@ class BundleImageCache {
// MARK: - 불러오기

/// 캐시된 PNG 데이터 로드
func load(for bundleID: String) -> Data? {
let fileURL = cacheDirectory.appendingPathComponent("\(bundleID).png")
func load(for bundleID: String, type: ImageType = .full) -> Data? {
let fileName = "\(bundleID)\(type.suffix).png"
let fileURL = cacheDirectory.appendingPathComponent(fileName)

guard fileManager.fileExists(atPath: fileURL.path) else {
return nil
Expand All @@ -77,8 +105,9 @@ class BundleImageCache {
// MARK: - 삭제

/// 특정 번들 캐시 삭제
func delete(for bundleID: String) {
let fileURL = cacheDirectory.appendingPathComponent("\(bundleID).png")
func delete(for bundleID: String, type: ImageType = .full) {
let fileName = "\(bundleID)\(type.suffix).png"
let fileURL = cacheDirectory.appendingPathComponent(fileName)

guard fileManager.fileExists(atPath: fileURL.path) else {
return
Expand All @@ -91,6 +120,12 @@ class BundleImageCache {
}
}

/// 특정 번들의 모든 타입 캐시 삭제
func deleteAll(for bundleID: String) {
delete(for: bundleID, type: .full)
delete(for: bundleID, type: .widget)
}

// MARK: - 전체 캐시 삭제

/// 모든 캐시 파일 삭제
Expand All @@ -104,41 +139,70 @@ class BundleImageCache {
} catch {
print("❌ [BundleCache] 전체 캐시 삭제 실패: \(error.localizedDescription)")
}

// 메타데이터 파일 삭제
clearMetadata()

// 위젯 타임라인 새로고침
reloadWidgets()
}

/// 메타데이터 파일 삭제
func clearMetadata() {
guard let fileURL = metadataFileURL else { return }

if fileManager.fileExists(atPath: fileURL.path) {
do {
try fileManager.removeItem(at: fileURL)
} catch {
print("❌ [BundleCache] 메타데이터 삭제 실패: \(error.localizedDescription)")
}
}
}

// MARK: - 캐시 존재 여부

/// 캐시 파일이 존재하는지 확인
func exists(for bundleID: String) -> Bool {
let fileURL = cacheDirectory.appendingPathComponent("\(bundleID).png")
func exists(for bundleID: String, type: ImageType = .full) -> Bool {
let fileName = "\(bundleID)\(type.suffix).png"
let fileURL = cacheDirectory.appendingPathComponent(fileName)
return fileManager.fileExists(atPath: fileURL.path)
}

// MARK: - 메타데이터 관리
// MARK: - 위젯 메타데이터 관리

/// 위젯용 뭉치 목록 저장
func saveWidgetBundleModels(_ bundles: [WidgetBundleModel]) {
guard let fileURL = metadataFileURL else {
print("❌ [BundleCache] 메타데이터 파일 URL을 찾을 수 없습니다.")
return
}

/// 사용 가능한 번들 목록 저장
func saveAvailableBundles(_ bundles: [AvailableBundle]) {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(bundles)
try data.write(to: metadataFileURL, options: .atomic)
try data.write(to: fileURL, options: .atomic)
} catch {
print("❌ [BundleCache] 메타데이터 저장 실패: \(error.localizedDescription)")
}
}

/// 사용 가능한 번들 목록 로드
func loadAvailableBundles() -> [AvailableBundle] {
guard fileManager.fileExists(atPath: metadataFileURL.path) else {
print("⚠️ [BundleCache] 메타데이터 파일이 없습니다.")
/// 위젯용 뭉치 목록 로드
func loadWidgetBundleModels() -> [WidgetBundleModel] {
guard let fileURL = metadataFileURL else {
print("❌ [BundleCache] 메타데이터 파일 URL을 찾을 수 없습니다.")
return []
}

guard fileManager.fileExists(atPath: fileURL.path) else {
return []
}

do {
let data = try Data(contentsOf: metadataFileURL)
let data = try Data(contentsOf: fileURL)
let decoder = JSONDecoder()
let bundles = try decoder.decode([AvailableBundle].self, from: data)
let bundles = try decoder.decode([WidgetBundleModel].self, from: data)
return bundles
} catch {
print("❌ [BundleCache] 메타데이터 로드 실패: \(error.localizedDescription)")
Expand All @@ -148,39 +212,58 @@ class BundleImageCache {

// MARK: - 동기화 메서드

/// 번들 추가 또는 업데이트 (이미지 + 메타데이터)
func syncBundle(id: String, name: String, imageData: Data) {
/// 뭉치 추가 또는 업데이트 (이미지 + 메타데이터)
/// - Parameters:
/// - id: 뭉치 ID
/// - name: 뭉치 이름
/// - fullImageData: 배경 포함 이미지 (앱용)
/// - widgetImageData: 배경 없는 이미지 (위젯용, optional)
/// - createdAt: 생성일
func syncBundle(id: String, name: String, fullImageData: Data, widgetImageData: Data? = nil, createdAt: Date = Date()) {
// 1. 이미지 저장
save(pngData: imageData, for: id)
save(pngData: fullImageData, for: id, type: .full)

// 위젯용 이미지가 있으면 저장
if let widgetData = widgetImageData {
save(pngData: widgetData, for: id, type: .widget)
}

// 2. 메타데이터 업데이트
var bundles = loadAvailableBundles()
let imagePath = "\(id).png"
var bundles = loadWidgetBundleModels()
// 위젯용 이미지가 있으면 _widget.png, 없으면 .png 사용
let imagePath = widgetImageData != nil ? "\(id)_widget.png" : "\(id).png"

if let index = bundles.firstIndex(where: { $0.id == id }) {
// 기존 번들 업데이트
bundles[index] = AvailableBundle(id: id, name: name, imagePath: imagePath)
// 기존 뭉치 업데이트
bundles[index] = WidgetBundleModel(id: id, name: name, imagePath: imagePath, createdAt: createdAt)
} else {
// 새 번들 추가
bundles.append(AvailableBundle(id: id, name: name, imagePath: imagePath))
// 새 뭉치 추가
bundles.append(WidgetBundleModel(id: id, name: name, imagePath: imagePath, createdAt: createdAt))
}

saveAvailableBundles(bundles)
saveWidgetBundleModels(bundles)

// 3. 위젯 타임라인 새로고침
reloadWidgets()
}

/// 번들 삭제 (이미지 + 메타데이터)
/// 뭉치 삭제 (이미지 + 메타데이터)
func removeBundle(id: String) {
// 1. 이미지 삭제
delete(for: id)
// 1. 이미지 삭제 (full + widget 모두)
deleteAll(for: id)

// 2. 메타데이터에서 제거
var bundles = loadAvailableBundles()
var bundles = loadWidgetBundleModels()
bundles.removeAll { $0.id == id }
saveAvailableBundles(bundles)
saveWidgetBundleModels(bundles)

print("✅ [BundleCache] 뭉치 완전 삭제: \(id)")

// 3. 위젯 타임라인 새로고침
reloadWidgets()
}

/// 이미지 경로로 이미지 로드
/// 이미지 경로로 이미지 로드 (위젯용)
func loadImageByPath(_ imagePath: String) -> Data? {
let fileURL = cacheDirectory.appendingPathComponent(imagePath)

Expand All @@ -197,6 +280,12 @@ class BundleImageCache {
}
}

// MARK: - 위젯 업데이트

private func reloadWidgets() {
WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
}

// MARK: - 캐시 정보

/// 전체 캐시 파일 개수 및 용량 반환
Expand Down Expand Up @@ -247,11 +336,32 @@ class BundleImageCache {
print("❌ [BundleCache] 파일 목록 조회 실패: \(error.localizedDescription)")
}
}

// MARK: - 하위 호환성 (기존 AvailableBundle 지원)

/// 기존 saveAvailableBundles 호환 (deprecated)
@available(*, deprecated, message: "Use syncBundle(id:name:fullImageData:widgetImageData:createdAt:) instead")
func saveAvailableBundles(_ bundles: [AvailableBundle]) {
// AvailableBundle -> WidgetBundleModel 변환
let widgetBundles = bundles.map {
WidgetBundleModel(id: $0.id, name: $0.name, imagePath: $0.imagePath, createdAt: .distantPast)
}
saveWidgetBundleModels(widgetBundles)
}

/// 기존 loadAvailableBundles 호환 (deprecated)
@available(*, deprecated, message: "Use loadWidgetBundleModels() instead")
func loadAvailableBundles() -> [AvailableBundle] {
let widgetBundles = loadWidgetBundleModels()
return widgetBundles.map {
AvailableBundle(id: $0.id, name: $0.name, imagePath: $0.imagePath)
}
}
}

/// 번들 메타데이터 구조체
/// 기존 번들 메타데이터 구조체 (하위 호환용)
struct AvailableBundle: Codable, Identifiable, Hashable {
let id: String // Firestore documentId
let name: String // 번들 이름
let imagePath: String // 앱 샌드박스 내 이미지 경로
let imagePath: String // App Group 내 이미지 경로
}
Loading