mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
use shared session for widgets
This commit is contained in:
@@ -140,6 +140,13 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A872EC0CA71550E4AB04E049 /* Shared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
@@ -257,6 +264,7 @@
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
0FB772A5B9601143383626CA /* Pods */,
|
||||
1754452DD81DA6620E279E51 /* Frameworks */,
|
||||
A872EC0CA71550E4AB04E049 /* Shared */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -362,6 +370,7 @@
|
||||
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A872EC0CA71550E4AB04E049 /* Shared */,
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
@@ -384,6 +393,7 @@
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A872EC0CA71550E4AB04E049 /* Shared */,
|
||||
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */,
|
||||
);
|
||||
name = WidgetExtension;
|
||||
|
||||
+77
-7
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
#if canImport(native_video_player)
|
||||
import native_video_player
|
||||
#endif
|
||||
|
||||
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
|
||||
let HEADERS_KEY = "immich.request_headers"
|
||||
@@ -36,7 +38,7 @@ extension UserDefaults {
|
||||
/// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
|
||||
class URLSessionManager: NSObject {
|
||||
static let shared = URLSessionManager()
|
||||
|
||||
|
||||
private(set) var session: URLSession
|
||||
let delegate: URLSessionManagerDelegate
|
||||
private static let cacheDir: URL = {
|
||||
@@ -144,7 +146,71 @@ class URLSessionManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
|
||||
static func setServerUrls(_ urls: [String]) {
|
||||
guard urls != serverUrls else { return }
|
||||
serverUrls = urls
|
||||
UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY)
|
||||
syncAuthCookies()
|
||||
}
|
||||
|
||||
@objc private static func cookiesDidChange(_ notification: Notification) {
|
||||
guard !isSyncing, !serverUrls.isEmpty else { return }
|
||||
syncAuthCookies()
|
||||
}
|
||||
|
||||
private static func syncAuthCookies() {
|
||||
let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host })
|
||||
let allCookies = cookieStorage.cookies ?? []
|
||||
let now = Date()
|
||||
|
||||
let serverAuthCookies = allCookies.filter {
|
||||
AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain)
|
||||
}
|
||||
|
||||
var sourceCookies: [String: HTTPCookie] = [:]
|
||||
for cookie in serverAuthCookies {
|
||||
if cookie.expiresDate.map({ $0 > now }) ?? true {
|
||||
sourceCookies[cookie.name] = cookie
|
||||
}
|
||||
}
|
||||
|
||||
isSyncing = true
|
||||
defer { isSyncing = false }
|
||||
|
||||
if sourceCookies.isEmpty {
|
||||
for cookie in serverAuthCookies {
|
||||
cookieStorage.deleteCookie(cookie)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for serverUrl in serverUrls {
|
||||
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
|
||||
let isSecure = serverUrl.hasPrefix("https")
|
||||
|
||||
for (_, source) in sourceCookies {
|
||||
if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) {
|
||||
continue
|
||||
}
|
||||
|
||||
var properties: [HTTPCookiePropertyKey: Any] = [
|
||||
.name: source.name,
|
||||
.value: source.value,
|
||||
.domain: domain,
|
||||
.path: "/",
|
||||
.expires: source.expiresDate ?? Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60),
|
||||
]
|
||||
if isSecure { properties[.secure] = "TRUE" }
|
||||
if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" }
|
||||
|
||||
if let cookie = HTTPCookie(properties: properties) {
|
||||
cookieStorage.setCookie(cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildSession(delegate: URLSessionDelegate) -> URLSession {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.urlCache = urlCache
|
||||
config.httpCookieStorage = cookieStorage
|
||||
@@ -168,7 +234,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
||||
) {
|
||||
handleChallenge(session, challenge, completionHandler)
|
||||
}
|
||||
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
@@ -177,7 +243,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
||||
) {
|
||||
handleChallenge(session, challenge, completionHandler, task: task)
|
||||
}
|
||||
|
||||
|
||||
func handleChallenge(
|
||||
_ session: URLSession,
|
||||
_ challenge: URLAuthenticationChallenge,
|
||||
@@ -190,7 +256,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
||||
default: completionHandler(.performDefaultHandling, nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func handleClientCertificate(
|
||||
_ session: URLSession,
|
||||
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
@@ -200,21 +266,23 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
||||
kSecAttrLabel as String: CLIENT_CERT_LABEL,
|
||||
kSecReturnRef as String: true,
|
||||
]
|
||||
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
if status == errSecSuccess, let identity = item {
|
||||
let credential = URLCredential(identity: identity as! SecIdentity,
|
||||
certificates: nil,
|
||||
persistence: .forSession)
|
||||
#if canImport(native_video_player)
|
||||
if #available(iOS 15, *) {
|
||||
VideoProxyServer.shared.session = session
|
||||
}
|
||||
#endif
|
||||
return completion(.useCredential, credential)
|
||||
}
|
||||
completion(.performDefaultHandling, nil)
|
||||
}
|
||||
|
||||
|
||||
private func handleBasicAuth(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask?,
|
||||
@@ -226,9 +294,11 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
||||
else {
|
||||
return completion(.performDefaultHandling, nil)
|
||||
}
|
||||
#if canImport(native_video_player)
|
||||
if #available(iOS 15, *) {
|
||||
VideoProxyServer.shared.session = session
|
||||
}
|
||||
#endif
|
||||
let credential = URLCredential(user: user, password: password, persistence: .forSession)
|
||||
completion(.useCredential, credential)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ struct ImageEntry: TimelineEntry {
|
||||
var metadata: Metadata = Metadata()
|
||||
|
||||
struct Metadata: Codable {
|
||||
var assetId: String? = nil
|
||||
var subtitle: String? = nil
|
||||
var error: WidgetError? = nil
|
||||
var deepLink: URL? = nil
|
||||
@@ -33,80 +34,39 @@ struct ImageEntry: TimelineEntry {
|
||||
date: entryDate,
|
||||
image: image,
|
||||
metadata: EntryMetadata(
|
||||
assetId: asset.id,
|
||||
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.png")
|
||||
let metadataURL = containerURL.appendingPathComponent(
|
||||
"\(key)_metadata.json"
|
||||
)
|
||||
|
||||
// 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 saveLast(for key: String, metadata: Metadata) {
|
||||
if let data = try? JSONEncoder().encode(metadata) {
|
||||
UserDefaults.group.set(data, forKey: "widget_last_\(key)")
|
||||
}
|
||||
}
|
||||
|
||||
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.png")
|
||||
let metadataURL = containerURL.appendingPathComponent(
|
||||
"\(key)_metadata.json"
|
||||
)
|
||||
|
||||
guard let imageData = try? Data(contentsOf: imageURL),
|
||||
let metadataJSON = try? Data(contentsOf: metadataURL),
|
||||
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 handleError(
|
||||
for key: String,
|
||||
api: ImmichAPI? = nil,
|
||||
error: WidgetError = .fetchFailed
|
||||
) -> Timeline<ImageEntry> {
|
||||
var timelineEntry = ImageEntry(
|
||||
date: Date.now,
|
||||
image: nil,
|
||||
metadata: EntryMetadata(error: error)
|
||||
)
|
||||
|
||||
// use cache if generic failed error
|
||||
// we want to show the other errors to the user since without intervention,
|
||||
// it will never succeed
|
||||
if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key)
|
||||
) async -> Timeline<ImageEntry> {
|
||||
// Try to show the last image from the URL cache for transient failures
|
||||
if error == .fetchFailed, let api = api,
|
||||
let data = UserDefaults.group.data(forKey: "widget_last_\(key)"),
|
||||
let cached = try? JSONDecoder().decode(Metadata.self, from: data),
|
||||
let assetId = cached.assetId,
|
||||
let image = try? await api.fetchImage(asset: Asset(id: assetId, type: .image))
|
||||
{
|
||||
timelineEntry = cachedEntry
|
||||
let entry = ImageEntry(date: Date.now, image: image, metadata: cached)
|
||||
return Timeline(entries: [entry], policy: .atEnd)
|
||||
}
|
||||
|
||||
return Timeline(entries: [timelineEntry], policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [ImageEntry(date: Date.now, metadata: Metadata(error: error))],
|
||||
policy: .atEnd
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
let IMMICH_SHARE_GROUP = "group.app.immich.share"
|
||||
// Constants and session configuration are in Shared/SharedURLSession.swift
|
||||
|
||||
enum WidgetError: Error, Codable {
|
||||
case noLogin
|
||||
@@ -104,87 +104,47 @@ struct Album: Codable, Equatable {
|
||||
// MARK: API
|
||||
|
||||
class ImmichAPI {
|
||||
typealias CustomHeaders = [String:String]
|
||||
struct ServerConfig {
|
||||
let serverEndpoint: String
|
||||
let sessionKey: String
|
||||
let customHeaders: CustomHeaders
|
||||
}
|
||||
|
||||
let serverConfig: ServerConfig
|
||||
let serverEndpoint: String
|
||||
|
||||
init() async throws {
|
||||
// fetch the credentials from the UserDefaults store that dart placed here
|
||||
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
|
||||
let serverURL = defaults.string(forKey: "widget_server_url"),
|
||||
let sessionKey = defaults.string(forKey: "widget_auth_token")
|
||||
guard let serverURL = UserDefaults.group.string(forKey: SERVER_URL_KEY),
|
||||
!serverURL.isEmpty
|
||||
else {
|
||||
throw WidgetError.noLogin
|
||||
}
|
||||
|
||||
if serverURL == "" || sessionKey == "" {
|
||||
throw WidgetError.noLogin
|
||||
}
|
||||
|
||||
// custom headers come in the form of KV pairs in JSON
|
||||
var customHeadersJSON = (defaults.string(forKey: "widget_custom_headers") ?? "")
|
||||
var customHeaders: CustomHeaders = [:]
|
||||
|
||||
if customHeadersJSON != "",
|
||||
let parsedHeaders = try? JSONDecoder().decode(CustomHeaders.self, from: customHeadersJSON.data(using: .utf8)!) {
|
||||
customHeaders = parsedHeaders
|
||||
}
|
||||
|
||||
serverConfig = ServerConfig(
|
||||
serverEndpoint: serverURL,
|
||||
sessionKey: sessionKey,
|
||||
customHeaders: customHeaders
|
||||
)
|
||||
serverEndpoint = serverURL
|
||||
}
|
||||
|
||||
private func buildRequestURL(
|
||||
serverConfig: ServerConfig,
|
||||
endpoint: String,
|
||||
params: [URLQueryItem] = []
|
||||
) -> URL? {
|
||||
guard let baseURL = URL(string: serverConfig.serverEndpoint) else {
|
||||
guard let baseURL = URL(string: serverEndpoint) else {
|
||||
fatalError("Invalid base URL")
|
||||
}
|
||||
|
||||
// Combine the base URL and API path
|
||||
let fullPath = baseURL.appendingPathComponent(
|
||||
endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
)
|
||||
|
||||
// Add the session key as a query parameter
|
||||
var components = URLComponents(
|
||||
url: fullPath,
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "sessionKey", value: serverConfig.sessionKey)
|
||||
]
|
||||
components?.queryItems?.append(contentsOf: params)
|
||||
if !params.isEmpty {
|
||||
components?.queryItems = params
|
||||
}
|
||||
|
||||
return components?.url
|
||||
}
|
||||
|
||||
func applyCustomHeaders(for request: inout URLRequest) {
|
||||
for (header, value) in serverConfig.customHeaders {
|
||||
request.addValue(value, forHTTPHeaderField: header)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
|
||||
async throws
|
||||
-> [Asset]
|
||||
{
|
||||
// get URL
|
||||
guard
|
||||
let searchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
endpoint: "/search/random"
|
||||
)
|
||||
let searchURL = buildRequestURL(endpoint: "/search/random")
|
||||
else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
@@ -193,20 +153,15 @@ class ImmichAPI {
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = try JSONEncoder().encode(filters)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// decode data
|
||||
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
|
||||
return try JSONDecoder().decode([Asset].self, from: data)
|
||||
}
|
||||
|
||||
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
|
||||
// get URL
|
||||
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
|
||||
guard
|
||||
let searchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
endpoint: "/memories",
|
||||
params: memoryParams
|
||||
)
|
||||
@@ -216,11 +171,8 @@ class ImmichAPI {
|
||||
|
||||
var request = URLRequest(url: searchURL)
|
||||
request.httpMethod = "GET"
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// decode data
|
||||
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
|
||||
return try JSONDecoder().decode([MemoryResult].self, from: data)
|
||||
}
|
||||
|
||||
@@ -230,7 +182,6 @@ class ImmichAPI {
|
||||
|
||||
guard
|
||||
let fetchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
endpoint: assetEndpoint,
|
||||
params: thumbnailParams
|
||||
)
|
||||
@@ -238,9 +189,13 @@ class ImmichAPI {
|
||||
throw .invalidURL
|
||||
}
|
||||
|
||||
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
|
||||
else {
|
||||
throw .invalidURL
|
||||
let request = URLRequest(url: fetchURL)
|
||||
guard let (data, _) = try? await URLSessionManager.shared.session.data(for: request) else {
|
||||
throw .fetchFailed
|
||||
}
|
||||
|
||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
throw .invalidImage
|
||||
}
|
||||
|
||||
let decodeOptions: [NSString: Any] = [
|
||||
@@ -263,23 +218,16 @@ class ImmichAPI {
|
||||
}
|
||||
|
||||
func fetchAlbums() async throws -> [Album] {
|
||||
// get URL
|
||||
guard
|
||||
let searchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
endpoint: "/albums"
|
||||
)
|
||||
let searchURL = buildRequestURL(endpoint: "/albums")
|
||||
else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: searchURL)
|
||||
request.httpMethod = "GET"
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// decode data
|
||||
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
|
||||
return try JSONDecoder().decode([Album].self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// Utils.swift
|
||||
// Runner
|
||||
//
|
||||
// Created by Alex Tran and Brandon Wees on 6/16/25.
|
||||
//
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
/// Crops the image to ensure width and height do not exceed maxSize.
|
||||
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
|
||||
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
|
||||
let canvas = CGSize(
|
||||
width: width,
|
||||
height: CGFloat(ceil(width / size.width * size.height))
|
||||
)
|
||||
let format = imageRendererFormat
|
||||
format.opaque = isOpaque
|
||||
return UIGraphicsImageRenderer(size: canvas, format: format).image {
|
||||
_ in draw(in: CGRect(origin: .zero, size: canvas))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,14 @@ struct ImmichMemoryProvider: TimelineProvider {
|
||||
Task {
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
completion(
|
||||
ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
|
||||
await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let memories = try? await api.fetchMemory(for: Date.now)
|
||||
else {
|
||||
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
|
||||
completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ struct ImmichMemoryProvider: TimelineProvider {
|
||||
dateOffset: 0
|
||||
)
|
||||
else {
|
||||
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
|
||||
completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ struct ImmichMemoryProvider: TimelineProvider {
|
||||
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
completion(
|
||||
ImageEntry.handleError(for: cacheKey, error: .noLogin)
|
||||
await ImageEntry.handleError(for: cacheKey, error: .noLogin)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -129,20 +129,20 @@ struct ImmichMemoryProvider: TimelineProvider {
|
||||
// Load or save a cached asset for when network conditions are bad
|
||||
if search.count == 0 {
|
||||
completion(
|
||||
ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
|
||||
await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
entries.append(contentsOf: search)
|
||||
} catch {
|
||||
completion(ImageEntry.handleError(for: cacheKey))
|
||||
completion(await ImageEntry.handleError(for: cacheKey, api: api))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// cache the last image
|
||||
try? entries.last!.cache(for: cacheKey)
|
||||
// save the last asset for fallback
|
||||
ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
|
||||
|
||||
completion(Timeline(entries: entries, policy: .atEnd))
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||
let cacheKey = "random_none_\(context.family.rawValue)"
|
||||
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
|
||||
return await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
|
||||
.first!
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||
dateOffset: 0
|
||||
)
|
||||
else {
|
||||
return ImageEntry.handleError(for: cacheKey).entries.first!
|
||||
return await ImageEntry.handleError(for: cacheKey, api: api).entries.first!
|
||||
}
|
||||
|
||||
return entry
|
||||
@@ -102,7 +102,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||
|
||||
// If we don't have a server config, return an entry with an error
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
return ImageEntry.handleError(for: cacheKey, error: .noLogin)
|
||||
return await ImageEntry.handleError(for: cacheKey, error: .noLogin)
|
||||
}
|
||||
|
||||
// build entries
|
||||
@@ -119,16 +119,16 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||
|
||||
// Load or save a cached asset for when network conditions are bad
|
||||
if search.count == 0 {
|
||||
return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
|
||||
return await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
|
||||
}
|
||||
|
||||
entries.append(contentsOf: search)
|
||||
} catch {
|
||||
return ImageEntry.handleError(for: cacheKey)
|
||||
return await ImageEntry.handleError(for: cacheKey, api: api)
|
||||
}
|
||||
|
||||
// cache the last image
|
||||
try? entries.last!.cache(for: cacheKey)
|
||||
// save the last asset for fallback
|
||||
ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
|
||||
|
||||
return Timeline(entries: entries, policy: .atEnd)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user