mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 18:22:37 -04:00 
			
		
		
		
	* feat(mobile): use custom headers when connecting in widget * delete log in android widget * chore: code review changes
		
			
				
	
	
		
			314 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			314 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
| import Foundation
 | |
| import SwiftUI
 | |
| import WidgetKit
 | |
| 
 | |
| let IMMICH_SHARE_GROUP = "group.app.immich.share"
 | |
| 
 | |
| enum WidgetError: Error, Codable {
 | |
|   case noLogin
 | |
|   case fetchFailed
 | |
|   case albumNotFound
 | |
|   case noAssetsAvailable
 | |
| }
 | |
| 
 | |
| enum FetchError: Error {
 | |
|   case unableToResize
 | |
|   case invalidImage
 | |
|   case invalidURL
 | |
|   case fetchFailed
 | |
| }
 | |
| 
 | |
| extension WidgetError: LocalizedError {
 | |
|   public var errorDescription: String? {
 | |
|     switch self {
 | |
|     case .noLogin:
 | |
|       return "Login to Immich"
 | |
| 
 | |
|     case .fetchFailed:
 | |
|       return "Unable to connect to your Immich instance"
 | |
| 
 | |
|     case .albumNotFound:
 | |
|       return "Album not found"
 | |
| 
 | |
|     case .noAssetsAvailable:
 | |
|       return "No assets available"
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| enum AssetType: String, Codable {
 | |
|   case image = "IMAGE"
 | |
|   case video = "VIDEO"
 | |
|   case audio = "AUDIO"
 | |
|   case other = "OTHER"
 | |
| }
 | |
| 
 | |
| struct Asset: Codable {
 | |
|   let id: String
 | |
|   let type: AssetType
 | |
| 
 | |
|   var deepLink: URL? {
 | |
|     return URL(string: "immich://asset?id=\(id)")
 | |
|   }
 | |
| }
 | |
| 
 | |
| struct SearchFilter: Codable {
 | |
|   var type = AssetType.image
 | |
|   var size = 1
 | |
|   var albumIds: [String] = []
 | |
|   var isFavorite: Bool? = nil
 | |
| }
 | |
| 
 | |
| struct MemoryResult: Codable {
 | |
|   let id: String
 | |
|   var assets: [Asset]
 | |
|   let type: String
 | |
| 
 | |
|   struct MemoryData: Codable {
 | |
|     let year: Int
 | |
|   }
 | |
| 
 | |
|   let data: MemoryData
 | |
| }
 | |
| 
 | |
| struct Album: Codable, Equatable {
 | |
|   let id: String
 | |
|   let albumName: String
 | |
| 
 | |
|   static let NONE = Album(id: "NONE", albumName: "None")
 | |
|   static let FAVORITES = Album(id: "FAVORITES", albumName: "Favorites")
 | |
| 
 | |
|   var filter: SearchFilter {
 | |
|     switch self {
 | |
|     case Album.NONE:
 | |
|       return SearchFilter()
 | |
|     case Album.FAVORITES:
 | |
|       return SearchFilter(isFavorite: true)
 | |
| 
 | |
|     // regular album
 | |
|     default:
 | |
|       return SearchFilter(albumIds: [id])
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   var isVirtual: Bool {
 | |
|     switch self {
 | |
|     case Album.NONE, Album.FAVORITES:
 | |
|       return true
 | |
|     default:
 | |
|       return false
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| // MARK: API
 | |
| 
 | |
| class ImmichAPI {
 | |
|   typealias CustomHeaders = [String:String]
 | |
|   struct ServerConfig {
 | |
|     let serverEndpoint: String
 | |
|     let sessionKey: String
 | |
|     let customHeaders: CustomHeaders
 | |
|   }
 | |
|   
 | |
|   let serverConfig: ServerConfig
 | |
| 
 | |
|   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")
 | |
|     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
 | |
|     )
 | |
|   }
 | |
| 
 | |
|   private func buildRequestURL(
 | |
|     serverConfig: ServerConfig,
 | |
|     endpoint: String,
 | |
|     params: [URLQueryItem] = []
 | |
|   ) -> URL? {
 | |
|     guard let baseURL = URL(string: serverConfig.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)
 | |
| 
 | |
|     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"
 | |
|       )
 | |
|     else {
 | |
|       throw URLError(.badURL)
 | |
|     }
 | |
| 
 | |
|     var request = URLRequest(url: searchURL)
 | |
|     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
 | |
|     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
 | |
|       )
 | |
|     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
 | |
|     return try JSONDecoder().decode([MemoryResult].self, from: data)
 | |
|   }
 | |
| 
 | |
|   func fetchImage(asset: Asset) async throws(FetchError) -> UIImage {
 | |
|     let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
 | |
|     let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
 | |
| 
 | |
|     guard
 | |
|       let fetchURL = buildRequestURL(
 | |
|         serverConfig: serverConfig,
 | |
|         endpoint: assetEndpoint,
 | |
|         params: thumbnailParams
 | |
|       )
 | |
|     else {
 | |
|       throw .invalidURL
 | |
|     }
 | |
| 
 | |
|     guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
 | |
|     else {
 | |
|       throw .invalidURL
 | |
|     }
 | |
| 
 | |
|     let decodeOptions: [NSString: Any] = [
 | |
|       kCGImageSourceCreateThumbnailFromImageAlways: true,
 | |
|       kCGImageSourceThumbnailMaxPixelSize: 512,
 | |
|       kCGImageSourceCreateThumbnailWithTransform: true,
 | |
|     ]
 | |
| 
 | |
|     guard
 | |
|       let thumbnail = CGImageSourceCreateThumbnailAtIndex(
 | |
|         imageSource,
 | |
|         0,
 | |
|         decodeOptions as CFDictionary
 | |
|       )
 | |
|     else {
 | |
|       throw .fetchFailed
 | |
|     }
 | |
| 
 | |
|     return UIImage(cgImage: thumbnail)
 | |
|   }
 | |
| 
 | |
|   func fetchAlbums() async throws -> [Album] {
 | |
|     // get URL
 | |
|     guard
 | |
|       let searchURL = buildRequestURL(
 | |
|         serverConfig: serverConfig,
 | |
|         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
 | |
|     return try JSONDecoder().decode([Album].self, from: data)
 | |
|   }
 | |
| }
 | |
| 
 | |
| // We need a shared cache for albums to efficiently handle the album picker queries
 | |
| actor AlbumCache {
 | |
|   static let shared = AlbumCache()
 | |
| 
 | |
|   private var api: ImmichAPI? = nil
 | |
|   private var albums: [Album]? = nil
 | |
| 
 | |
|   func getAlbums(refresh: Bool = false) async throws -> [Album] {
 | |
|     // Check the API before we try to show cached albums
 | |
|     // Sometimes iOS caches this object and keeps it around
 | |
|     // even after nuking the timeline
 | |
| 
 | |
|     api = try? await ImmichAPI()
 | |
| 
 | |
|     guard api != nil else {
 | |
|       throw WidgetError.noLogin
 | |
|     }
 | |
| 
 | |
|     if let albums, !refresh {
 | |
|       return albums
 | |
|     }
 | |
| 
 | |
|     let fetched = try await api!.fetchAlbums()
 | |
|     albums = fetched
 | |
|     return fetched
 | |
|   }
 | |
| }
 |