cache the last image an ios widget fetched and use if a fetch fails in a future timeline build

This commit is contained in:
bwees 2025-07-08 12:16:30 -05:00
parent 172388c455
commit e211b47fad
No known key found for this signature in database
8 changed files with 298 additions and 138 deletions

View File

@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@ -117,8 +117,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync; path = Sync;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -473,10 +471,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@ -505,10 +507,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";

View File

@ -1,59 +0,0 @@
import SwiftUI
import WidgetKit
func buildEntry(
api: ImmichAPI,
asset: Asset,
dateOffset: Int,
subtitle: String? = nil
)
async throws -> ImageEntry
{
let entryDate = Calendar.current.date(
byAdding: .minute,
value: dateOffset * 20,
to: Date.now
)!
let image = try await api.fetchImage(asset: asset)
return ImageEntry(date: entryDate, image: image, subtitle: subtitle, deepLink: asset.deepLink)
}
func generateRandomEntries(
api: ImmichAPI,
now: Date,
count: Int,
albumId: String? = nil,
subtitle: String? = nil
)
async throws -> [ImageEntry]
{
var entries: [ImageEntry] = []
let albumIds = albumId != nil ? [albumId!] : []
let randomAssets = try await api.fetchSearchResults(
with: SearchFilters(size: count, albumIds: albumIds)
)
await withTaskGroup(of: ImageEntry?.self) { group in
for (dateOffset, asset) in randomAssets.enumerated() {
group.addTask {
return try? await buildEntry(
api: api,
asset: asset,
dateOffset: dateOffset,
subtitle: subtitle
)
}
}
for await result in group {
if let entry = result {
entries.append(entry)
}
}
}
return entries
}

View File

@ -0,0 +1,170 @@
import SwiftUI
import WidgetKit
typealias EntryMetadata = ImageEntry.Metadata
struct ImageEntry: TimelineEntry {
let date: Date
var image: UIImage?
var metadata: Metadata = Metadata()
struct Metadata: Codable {
var subtitle: String? = nil
var error: WidgetError? = nil
var deepLink: URL? = nil
}
// Resizes the stored image to a maximum width of 450 pixels
mutating func resize() {
if image == nil || image!.size.height < 450 || image!.size.width < 450 {
return
}
image = image?.resized(toWidth: 450)
if image == nil {
metadata.error = .unableToResize
}
}
static func build(
api: ImmichAPI,
asset: Asset,
dateOffset: Int,
subtitle: String? = nil
)
async throws -> Self
{
let entryDate = Calendar.current.date(
byAdding: .minute,
value: dateOffset * 20,
to: Date.now
)!
let image = try await api.fetchImage(asset: asset)
return Self(
date: entryDate,
image: image,
metadata: ImageEntry.Metadata(
subtitle: subtitle,
deepLink: asset.deepLink
)
)
}
func cache(for key: String) throws {
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.txt")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.txt"
)
// build metadata JSON
let entryMetadata = try JSONEncoder().encode(self.metadata)
// write to disk
try self.image?.pngData()?.write(to: imageURL, options: .atomic)
try entryMetadata.write(to: metadataURL, options: .atomic)
}
}
static func loadCached(for key: String, at date: Date = Date.now)
-> ImageEntry?
{
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.txt")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.txt"
)
guard let imageData = try? Data(contentsOf: imageURL),
let metadataJSON = try? Data(contentsOf: metadataURL)
else {
// cache miss
return nil
}
guard
let decodedMetadata = try? JSONDecoder().decode(
Metadata.self,
from: metadataJSON
)
else {
return nil
}
return ImageEntry(
date: date,
image: UIImage(data: imageData),
metadata: decodedMetadata
)
}
return nil
}
static func handleCacheFallback(
for key: String,
error: WidgetError = .fetchFailed
) -> Timeline<ImageEntry> {
var timelineEntry = ImageEntry(
date: Date.now,
image: nil,
metadata: EntryMetadata(error: error)
)
// skip cache if album not found or no login
// we want to show these errors to the user since without intervention,
// it will never succeed
if error != .noLogin && error != .albumNotFound {
if let cachedEntry = ImageEntry.loadCached(for: key) {
timelineEntry = cachedEntry
}
}
return Timeline(entries: [timelineEntry], policy: .atEnd)
}
}
func generateRandomEntries(
api: ImmichAPI,
now: Date,
count: Int,
albumId: String? = nil,
subtitle: String? = nil
)
async throws -> [ImageEntry]
{
var entries: [ImageEntry] = []
let albumIds = albumId != nil ? [albumId!] : []
let randomAssets = try await api.fetchSearchResults(
with: SearchFilters(size: count, albumIds: albumIds)
)
await withTaskGroup(of: ImageEntry?.self) { group in
for (dateOffset, asset) in randomAssets.enumerated() {
group.addTask {
return try? await ImageEntry.build(
api: api,
asset: asset,
dateOffset: dateOffset,
subtitle: subtitle
)
}
}
for await result in group {
if let entry = result {
entries.append(entry)
}
}
}
return entries
}

View File

@ -1,27 +1,6 @@
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
struct ImageEntry: TimelineEntry {
let date: Date
var image: UIImage?
var subtitle: String? = nil
var error: WidgetError? = nil
var deepLink: URL? = nil
// Resizes the stored image to a maximum width of 450 pixels
mutating func resize() {
if (image == nil || image!.size.height < 450 || image!.size.width < 450 ) {
return
}
image = image?.resized(toWidth: 450)
if image == nil {
error = .unableToResize
}
}
}
struct ImmichWidgetView: View { struct ImmichWidgetView: View {
var entry: ImageEntry var entry: ImageEntry
@ -29,7 +8,7 @@ struct ImmichWidgetView: View {
if entry.image == nil { if entry.image == nil {
VStack { VStack {
Image("LaunchImage") Image("LaunchImage")
Text(entry.error?.errorDescription ?? "") Text(entry.metadata.error?.errorDescription ?? "")
.minimumScaleFactor(0.25) .minimumScaleFactor(0.25)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@ -44,7 +23,7 @@ struct ImmichWidgetView: View {
) )
VStack { VStack {
Spacer() Spacer()
if let subtitle = entry.subtitle { if let subtitle = entry.metadata.subtitle {
Text(subtitle) Text(subtitle)
.foregroundColor(.white) .foregroundColor(.white)
.padding(8) .padding(8)
@ -55,7 +34,7 @@ struct ImmichWidgetView: View {
} }
.padding(16) .padding(16)
} }
.widgetURL(entry.deepLink) .widgetURL(entry.metadata.deepLink)
} }
} }
} }
@ -70,7 +49,9 @@ struct ImmichWidgetView: View {
ImageEntry( ImageEntry(
date: date, date: date,
image: UIImage(named: "ImmichLogo"), image: UIImage(named: "ImmichLogo"),
subtitle: "1 year ago" metadata: ImageEntry.Metadata(
subtitle: "1 year ago"
)
) )
} }
) )

View File

@ -2,7 +2,7 @@ import Foundation
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
enum WidgetError: Error { enum WidgetError: Error, Codable {
case noLogin case noLogin
case fetchFailed case fetchFailed
case unknown case unknown
@ -75,6 +75,8 @@ struct Album: Codable {
let albumName: String let albumName: String
} }
let IMMICH_SHARE_GROUP = "group.app.immich.share"
// MARK: API // MARK: API
class ImmichAPI { class ImmichAPI {
@ -86,7 +88,7 @@ class ImmichAPI {
init() async throws { init() async throws {
// fetch the credentials from the UserDefaults store that dart placed here // fetch the credentials from the UserDefaults store that dart placed here
guard let defaults = UserDefaults(suiteName: "group.app.immich.share"), guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
let serverURL = defaults.string(forKey: "widget_server_url"), let serverURL = defaults.string(forKey: "widget_server_url"),
let sessionKey = defaults.string(forKey: "widget_auth_token") let sessionKey = defaults.string(forKey: "widget_auth_token")
else { else {
@ -190,17 +192,24 @@ class ImmichAPI {
throw .invalidURL throw .invalidURL
} }
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil) else { guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
else {
throw .invalidURL throw .invalidURL
} }
let decodeOptions: [NSString: Any] = [ let decodeOptions: [NSString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 400, kCGImageSourceThumbnailMaxPixelSize: 400,
kCGImageSourceCreateThumbnailWithTransform: true kCGImageSourceCreateThumbnailWithTransform: true,
] ]
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions as CFDictionary) else { guard
let thumbnail = CGImageSourceCreateThumbnailAtIndex(
imageSource,
0,
decodeOptions as CFDictionary
)
else {
throw .fetchFailed throw .fetchFailed
} }

View File

@ -7,14 +7,17 @@
import UIKit import UIKit
extension UIImage { extension UIImage {
/// Crops the image to ensure width and height do not exceed maxSize. /// Crops the image to ensure width and height do not exceed maxSize.
/// Keeps original aspect ratio and crops excess equally from edges (center crop). /// Keeps original aspect ratio and crops excess equally from edges (center crop).
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? { func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
let canvas = CGSize(width: width, height: CGFloat(ceil(width/size.width * size.height))) let canvas = CGSize(
let format = imageRendererFormat width: width,
format.opaque = isOpaque height: CGFloat(ceil(width / size.width * size.height))
return UIGraphicsImageRenderer(size: canvas, format: format).image { )
_ in draw(in: CGRect(origin: .zero, size: canvas)) let format = imageRendererFormat
} format.opaque = isOpaque
return UIGraphicsImageRenderer(size: canvas, format: format).image {
_ in draw(in: CGRect(origin: .zero, size: canvas))
} }
}
} }

View File

@ -21,19 +21,31 @@ struct ImmichMemoryProvider: TimelineProvider {
) { ) {
Task { Task {
guard let api = try? await ImmichAPI() else { guard let api = try? await ImmichAPI() else {
completion(ImageEntry(date: Date(), image: nil, error: .noLogin)) completion(
ImageEntry(
date: Date(),
image: nil,
metadata: EntryMetadata(error: .noLogin)
)
)
return return
} }
guard let memories = try? await api.fetchMemory(for: Date.now) guard let memories = try? await api.fetchMemory(for: Date.now)
else { else {
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) completion(
ImageEntry(
date: Date(),
image: nil,
metadata: EntryMetadata(error: .fetchFailed)
)
)
return return
} }
for memory in memories { for memory in memories {
if let asset = memory.assets.first(where: { $0.type == .image }), if let asset = memory.assets.first(where: { $0.type == .image }),
var entry = try? await buildEntry( var entry = try? await ImageEntry.build(
api: api, api: api,
asset: asset, asset: asset,
dateOffset: 0, dateOffset: 0,
@ -52,18 +64,30 @@ struct ImmichMemoryProvider: TimelineProvider {
with: SearchFilters(size: 1) with: SearchFilters(size: 1)
).first ).first
else { else {
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) completion(
ImageEntry(
date: Date(),
image: nil,
metadata: EntryMetadata(error: .fetchFailed)
)
)
return return
} }
guard guard
var imageEntry = try? await buildEntry( var imageEntry = try? await ImageEntry.build(
api: api, api: api,
asset: randomImage, asset: randomImage,
dateOffset: 0 dateOffset: 0
) )
else { else {
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) completion(
ImageEntry(
date: Date(),
image: nil,
metadata: EntryMetadata(error: .fetchFailed)
)
)
return return
} }
@ -80,9 +104,12 @@ struct ImmichMemoryProvider: TimelineProvider {
var entries: [ImageEntry] = [] var entries: [ImageEntry] = []
let now = Date() let now = Date()
let cacheKey = "memory_\(context.family.rawValue)"
guard let api = try? await ImmichAPI() else { guard let api = try? await ImmichAPI() else {
entries.append(ImageEntry(date: now, image: nil, error: .noLogin)) completion(
completion(Timeline(entries: entries, policy: .atEnd)) ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin)
)
return return
} }
@ -95,7 +122,7 @@ struct ImmichMemoryProvider: TimelineProvider {
for asset in memory.assets { for asset in memory.assets {
if asset.type == .image && totalAssets < 12 { if asset.type == .image && totalAssets < 12 {
group.addTask { group.addTask {
try? await buildEntry( try? await ImageEntry.build(
api: api, api: api,
asset: asset, asset: asset,
dateOffset: totalAssets, dateOffset: totalAssets,
@ -132,7 +159,8 @@ struct ImmichMemoryProvider: TimelineProvider {
// If we fail to fetch images, we still want to add an entry // If we fail to fetch images, we still want to add an entry
// with a nil image and an error // with a nil image and an error
if entries.count == 0 { if entries.count == 0 {
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed)) completion(ImageEntry.handleCacheFallback(for: cacheKey))
return
} }
// Resize all images to something that can be stored by iOS // Resize all images to something that can be stored by iOS
@ -140,6 +168,9 @@ struct ImmichMemoryProvider: TimelineProvider {
entries[i].resize() entries[i].resize()
} }
// cache the last image
try? entries.last!.cache(for: cacheKey)
completion(Timeline(entries: entries, policy: .atEnd)) completion(Timeline(entries: entries, policy: .atEnd))
} }
} }

View File

@ -54,7 +54,7 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
struct ImmichRandomProvider: AppIntentTimelineProvider { struct ImmichRandomProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> ImageEntry { func placeholder(in context: Context) -> ImageEntry {
ImageEntry(date: Date(), image: nil) ImageEntry(date: Date())
} }
func snapshot( func snapshot(
@ -64,7 +64,11 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
-> ImageEntry -> ImageEntry
{ {
guard let api = try? await ImmichAPI() else { guard let api = try? await ImmichAPI() else {
return ImageEntry(date: Date(), image: nil, error: .noLogin) return ImageEntry(
date: Date(),
image: nil,
metadata: EntryMetadata(error: .noLogin)
)
} }
guard guard
@ -72,17 +76,25 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
with: SearchFilters(size: 1) with: SearchFilters(size: 1)
).first ).first
else { else {
return ImageEntry(date: Date(), image: nil, error: .fetchFailed) return ImageEntry(
date: Date(),
image: nil,
metadata: EntryMetadata(error: .fetchFailed)
)
} }
guard guard
var entry = try? await buildEntry( var entry = try? await ImageEntry.build(
api: api, api: api,
asset: randomImage, asset: randomImage,
dateOffset: 0 dateOffset: 0
) )
else { else {
return ImageEntry(date: Date(), image: nil, error: .fetchFailed) return ImageEntry(
date: Date(),
image: nil,
metadata: EntryMetadata(error: .fetchFailed)
)
} }
entry.resize() entry.resize()
@ -99,30 +111,34 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
var entries: [ImageEntry] = [] var entries: [ImageEntry] = []
let now = Date() let now = Date()
// If we don't have a server config, return an entry with an error
guard let api = try? await ImmichAPI() else {
entries.append(ImageEntry(date: now, image: nil, error: .noLogin))
return Timeline(entries: entries, policy: .atEnd)
}
// nil if album is NONE or nil // nil if album is NONE or nil
let albumId = let albumId =
configuration.album?.id != "NONE" ? configuration.album?.id : nil configuration.album?.id != "NONE" ? configuration.album?.id : nil
var albumName: String? = albumId != nil ? configuration.album?.albumName : nil let albumName: String? =
albumId != nil ? configuration.album?.albumName : nil
let cacheKey = "random_\(albumId ?? "none")_\(context.family.rawValue)"
// If we don't have a server config, return an entry with an error
guard let api = try? await ImmichAPI() else {
return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin)
}
if albumId != nil { if albumId != nil {
// make sure the album exists on server, otherwise show error // make sure the album exists on server, otherwise show error
guard let albums = try? await api.fetchAlbums() else { guard let albums = try? await api.fetchAlbums() else {
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed)) return ImageEntry.handleCacheFallback(for: cacheKey)
return Timeline(entries: entries, policy: .atEnd)
} }
if !albums.contains(where: { $0.id == albumId }) { if !albums.contains(where: { $0.id == albumId }) {
entries.append(ImageEntry(date: now, image: nil, error: .albumNotFound)) return ImageEntry.handleCacheFallback(
return Timeline(entries: entries, policy: .atEnd) for: cacheKey,
error: .albumNotFound
)
} }
} }
// build entries
entries.append( entries.append(
contentsOf: (try? await generateRandomEntries( contentsOf: (try? await generateRandomEntries(
api: api, api: api,
@ -134,9 +150,9 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
?? [] ?? []
) )
// If we fail to fetch images, we still want to add an entry with a nil image and an error // Load or save a cached asset for when network conditions are bad
if entries.count == 0 { if entries.count == 0 {
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed)) return ImageEntry.handleCacheFallback(for: cacheKey)
} }
// Resize all images to something that can be stored by iOS // Resize all images to something that can be stored by iOS
@ -144,6 +160,9 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
entries[i].resize() entries[i].resize()
} }
// cache the last image
try? entries.last!.cache(for: cacheKey)
return Timeline(entries: entries, policy: .atEnd) return Timeline(entries: entries, policy: .atEnd)
} }
} }