1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import Foundation
/// Synchronous disk cache for the first-page feed response.
///
/// Allows `FeedViewModel.init()` to pre-populate `galleries` before the first
/// SwiftUI body evaluation, so the feed renders with real content immediately
/// while the background network refresh runs.
///
/// Entries are filed under the account that fetched them — a feed is personal,
/// and switching accounts must not flash the previous one's galleries.
final class FeedCache: @unchecked Sendable {
static let shared = FeedCache()
private let directory: URL
private init() {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
directory = caches.appendingPathComponent("grain_feed_cache", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
/// Load cached galleries for `key`. Returns `[]` if no cache exists or decode fails.
func load(key: String) -> [GrainGallery] {
guard let data = try? Data(contentsOf: fileURL(for: key)),
let galleries = try? JSONDecoder().decode([GrainGallery].self, from: data)
else { return [] }
return galleries
}
/// Persist `galleries` to disk for `key`, filed under `did`.
/// No-ops on empty arrays.
///
/// The owning account is a parameter rather than a lookup because saving
/// happens on a detached task: resolving "who is signed in" here would read
/// it *after* the fetch, so switching accounts while a feed was in flight
/// filed one account's feed under the other's cache.
func save(_ galleries: [GrainGallery], key: String, did: String?) {
guard !galleries.isEmpty,
let data = try? JSONEncoder().encode(galleries)
else { return }
try? data.write(to: fileURL(for: key, did: did), options: .atomic)
}
/// Drop every entry belonging to `did` (that account was signed out).
func purge(did: String) {
let suffix = "\(Self.accountSeparator)\(Self.sanitize(did)).json"
guard let entries = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { return }
for entry in entries where entry.lastPathComponent.hasSuffix(suffix) {
try? FileManager.default.removeItem(at: entry)
}
}
/// Re-file entries written before the cache was account-scoped under `did`,
/// the account that must have written them.
func adoptLegacyEntries(did: String) {
guard let entries = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { return }
for entry in entries where !entry.lastPathComponent.contains(Self.accountSeparator) {
let key = entry.deletingPathExtension().lastPathComponent
try? FileManager.default.moveItem(at: entry, to: fileURL(for: key, did: did))
}
}
private static let accountSeparator = "--acct-"
private static func sanitize(_ value: String) -> String {
value
.replacingOccurrences(of: "/", with: "-")
.replacingOccurrences(of: ":", with: "-")
.replacingOccurrences(of: " ", with: "_")
}
private func fileURL(for key: String, did: String? = AccountScopedStorage.activeAccountID) -> URL {
let safe = Self.sanitize(key)
guard let did else { return directory.appendingPathComponent("\(safe).json") }
return directory.appendingPathComponent("\(safe)\(Self.accountSeparator)\(Self.sanitize(did)).json")
}
}