use shared session for widgets

This commit is contained in:
mertalev
2026-03-07 14:06:39 -05:00
parent 0a79dd1228
commit 3d9be2477b
22 changed files with 307 additions and 559 deletions
+19 -59
View File
@@ -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
)
}
}
+21 -73
View File
@@ -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)
}