@@ -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