Skip to content

Commit 1e1c8ab

Browse files
committed
feat: add disk cache for faster restart
- Save image cache to disk before restarting (display count change) - Load image cache from disk on startup - Cache is only loaded if less than 30 seconds old - Delete stale caches automatically Signed-off-by: Toni Förster <toni.foerster@icloud.com>
1 parent 4c9c71f commit 1e1c8ab

2 files changed

Lines changed: 114 additions & 0 deletions

File tree

Thaw/Main/AppState.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,9 @@ final class AppState: ObservableObject {
344344
isRestarting = true
345345

346346
Task { @MainActor [diagLog] in
347+
// Save image cache to disk before restarting so new instance can load it
348+
imageCache.saveToDisk()
349+
347350
let config = NSWorkspace.OpenConfiguration()
348351
config.activates = false
349352
config.addsToRecentItems = false

Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,117 @@ final class MenuBarItemImageCache: ObservableObject {
8989
func performSetup(with appState: AppState) {
9090
self.appState = appState
9191
configureCancellables()
92+
93+
// Try to load cached images from disk
94+
loadFromDisk()
95+
}
96+
97+
// MARK: Disk Persistence
98+
99+
/// Path to the cache file in Caches directory.
100+
private static var cacheFileURL: URL? {
101+
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
102+
return cacheDir?.appendingPathComponent("com.stonerl.thaw/imageCache.json")
103+
}
104+
105+
/// Maximum age of disk cache before it's considered stale (30 seconds).
106+
private static let maxCacheAgeSeconds: TimeInterval = 30
107+
108+
/// Saves the image cache to disk for faster restart.
109+
func saveToDisk() {
110+
guard !images.isEmpty else { return }
111+
112+
guard let url = Self.cacheFileURL else { return }
113+
114+
Task.detached(priority: .background) { [weak self] in
115+
guard let self else { return }
116+
117+
let cacheData = self.images.map { tag, image -> (String, Data)? in
118+
let nsImage = NSImage(cgImage: image.cgImage, size: image.scaledSize)
119+
guard let tiffData = nsImage.tiffRepresentation,
120+
let bitmap = NSBitmapImageRep(data: tiffData),
121+
let pngData = bitmap.representation(using: .png, properties: [:])
122+
else { return nil }
123+
124+
let tagString = "\(tag.namespace):\(tag.title)"
125+
return (tagString, pngData)
126+
}.compactMap { $0 }
127+
128+
guard cacheData.count == self.images.count else { return }
129+
130+
do {
131+
let directoryURL = url.deletingLastPathComponent()
132+
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
133+
134+
let json: [String: Any] = [
135+
"timestamp": Date().timeIntervalSince1970,
136+
"images": Dictionary(uniqueKeysWithValues: cacheData.map { ($0.0, $0.1.base64EncodedString()) }),
137+
]
138+
let jsonData = try JSONSerialization.data(withJSONObject: json, options: [])
139+
try jsonData.write(to: url)
140+
141+
MenuBarItemImageCache.diagLog.debug("Saved \(cacheData.count) images to disk cache")
142+
} catch {
143+
MenuBarItemImageCache.diagLog.error("Failed to save image cache to disk: \(error)")
144+
}
145+
}
146+
}
147+
148+
/// Loads cached images from disk.
149+
@MainActor
150+
private func loadFromDisk() {
151+
guard let url = Self.cacheFileURL,
152+
FileManager.default.fileExists(atPath: url.path)
153+
else { return }
154+
155+
Task.detached(priority: .background) { [weak self] in
156+
guard let self else { return }
157+
158+
do {
159+
let jsonData = try Data(contentsOf: url)
160+
guard let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
161+
let timestamp = json["timestamp"] as? TimeInterval,
162+
let imagesDict = json["images"] as? [String: String] else { return }
163+
164+
// Check if cache is stale (older than 30 seconds)
165+
let cacheAge = Date().timeIntervalSince1970 - timestamp
166+
if cacheAge > Self.maxCacheAgeSeconds {
167+
MenuBarItemImageCache.diagLog.debug("Disk cache is \(Int(cacheAge))s old, deleting stale cache")
168+
try? FileManager.default.removeItem(at: url)
169+
return
170+
}
171+
172+
var loadedImages = [MenuBarItemTag: CapturedImage]()
173+
174+
for (tagString, base64) in imagesDict {
175+
guard let data = Data(base64Encoded: base64),
176+
let image = NSImage(data: data),
177+
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil)
178+
else { continue }
179+
180+
let parts = tagString.split(separator: ":", maxSplits: 1)
181+
guard parts.count == 2 else { continue }
182+
183+
let namespace = String(parts[0])
184+
let title = String(parts[1])
185+
let tag = MenuBarItemTag(namespace: .string(namespace), title: title)
186+
187+
let captured = CapturedImage(cgImage: cgImage, scale: image.size.width > 0 ? CGFloat(cgImage.width) / image.size.width : 1.0)
188+
loadedImages[tag] = captured
189+
}
190+
191+
if !loadedImages.isEmpty {
192+
await MainActor.run {
193+
for (tag, image) in loadedImages {
194+
self.images[tag] = image
195+
}
196+
MenuBarItemImageCache.diagLog.debug("Loaded \(loadedImages.count) images from disk cache (\(Int(cacheAge))s old)")
197+
}
198+
}
199+
} catch {
200+
MenuBarItemImageCache.diagLog.error("Failed to load image cache from disk: \(error)")
201+
}
202+
}
92203
}
93204

94205
/// Configures the internal observers for the cache.

0 commit comments

Comments
 (0)