mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05: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
 | 
						|
  }
 | 
						|
}
 |