feat: ios widget supports alternate server URLs

This commit is contained in:
bwees 2025-06-23 17:41:55 -05:00
parent 1923f1a887
commit 99a9914da2
No known key found for this signature in database
6 changed files with 125 additions and 22 deletions

View File

@ -17,6 +17,14 @@ enum AssetType: String, Codable {
case other = "OTHER" case other = "OTHER"
} }
struct ServerWellKnown: Codable {
struct APIInfo: Codable{
let endpoint: String
}
let api: APIInfo
}
struct SearchResult: Codable { struct SearchResult: Codable {
let id: String let id: String
let type: AssetType let type: AssetType
@ -66,13 +74,50 @@ class ImmichAPI {
if serverURL == "" || sessionKey == "" { if serverURL == "" || sessionKey == "" {
throw WidgetError.noLogin throw WidgetError.noLogin
} }
serverConfig = ServerConfig( // check if the stored value is an array of URLs
serverEndpoint: serverURL, if serverURL.starts(with: "[") {
sessionKey: sessionKey guard let urls = try? JSONDecoder().decode([String].self, from: serverURL.data(using: .utf8)!) else {
) throw WidgetError.noLogin
}
for url in urls {
guard let endpointURL = URL(string: url) else { continue }
if let apiURL = await Self.validateServer(at: endpointURL) {
serverConfig = ServerConfig(
serverEndpoint: apiURL.absoluteString,
sessionKey: sessionKey
)
return
}
}
throw WidgetError.fetchFailed
} else {
serverConfig = ServerConfig(
serverEndpoint: serverURL,
sessionKey: sessionKey
)
}
} }
private static func validateServer(at endpointURL: URL) async -> URL? {
let baseURL = URL(string: endpointURL.scheme! + "://" + endpointURL.host!)!
var pingURL = baseURL
pingURL.appendPathComponent(".well-known")
pingURL.appendPathComponent("immich")
guard let (serverPingJSON, _) = try? await URLSession.shared.data(from: pingURL) else { return nil }
guard let apiInfo = try? JSONDecoder().decode(ServerWellKnown.self, from: serverPingJSON) else { return nil }
var apiURL = baseURL
apiURL.appendPathComponent(apiInfo.api.endpoint)
return apiURL
}
private func buildRequestURL( private func buildRequestURL(
serverConfig: ServerConfig, serverConfig: ServerConfig,
endpoint: String, endpoint: String,

View File

@ -20,8 +20,11 @@ struct ImmichMemoryProvider: TimelineProvider {
completion: @escaping @Sendable (ImageEntry) -> Void completion: @escaping @Sendable (ImageEntry) -> Void
) { ) {
Task { Task {
guard let api = try? await ImmichAPI() else { var api: ImmichAPI
completion(ImageEntry(date: Date(), image: nil, error: .noLogin)) do {
api = try await ImmichAPI()
} catch let error as WidgetError {
completion(ImageEntry(date: Date(), image: nil, error: error))
return return
} }
@ -79,9 +82,13 @@ struct ImmichMemoryProvider: TimelineProvider {
Task { Task {
var entries: [ImageEntry] = [] var entries: [ImageEntry] = []
let now = Date() let now = Date()
guard let api = try? await ImmichAPI() else {
entries.append(ImageEntry(date: now, image: nil, error: .noLogin)) var api: ImmichAPI
do {
api = try await ImmichAPI()
} catch let error as WidgetError {
entries.append(ImageEntry(date: now, image: nil, error: error))
completion(Timeline(entries: entries, policy: .atEnd)) completion(Timeline(entries: entries, policy: .atEnd))
return return
} }

View File

@ -63,10 +63,15 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
) async ) async
-> ImageEntry -> ImageEntry
{ {
guard let api = try? await ImmichAPI() else { var api: ImmichAPI
return ImageEntry(date: Date(), image: nil, error: .noLogin) do {
api = try await ImmichAPI()
} catch let error as WidgetError {
return ImageEntry(date: Date(), image: nil, error: error)
} catch {
return ImageEntry(date: Date(), image: nil, error: .fetchFailed)
} }
guard guard
let randomImage = try? await api.fetchSearchResults( let randomImage = try? await api.fetchSearchResults(
with: SearchFilters(size: 1) with: SearchFilters(size: 1)
@ -100,15 +105,21 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
let now = Date() let now = Date()
// If we don't have a server config, return an entry with an error // If we don't have a server config, return an entry with an error
guard let api = try? await ImmichAPI() else { var api: ImmichAPI
entries.append(ImageEntry(date: now, image: nil, error: .noLogin)) do {
api = try await ImmichAPI()
} catch let error as WidgetError {
entries.append(ImageEntry(date: now, image: nil, error: error))
return Timeline(entries: entries, policy: .atEnd)
} catch {
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
return Timeline(entries: entries, policy: .atEnd) 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
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

View File

@ -118,10 +118,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
}) async { }) async {
await _apiService.setAccessToken(accessToken); await _apiService.setAccessToken(accessToken);
await _widgetService.writeCredentials( await _widgetService.writeSessionKey(accessToken);
Store.get(StoreKey.serverEndpoint), await _widgetService.writeServerList();
accessToken,
);
// Get the deviceid from the store if it exists, otherwise generate a new one // Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId = String deviceId =
@ -190,6 +188,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> saveLocalEndpoint(String url) async { Future<void> saveLocalEndpoint(String url) async {
await Store.put(StoreKey.localEndpoint, url); await Store.put(StoreKey.localEndpoint, url);
await _widgetService.writeServerList();
} }
String? getSavedWifiName() { String? getSavedWifiName() {

View File

@ -1,5 +1,10 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/repositories/widget.repository.dart'; import 'package:immich_mobile/repositories/widget.repository.dart';
final widgetServiceProvider = Provider((ref) { final widgetServiceProvider = Provider((ref) {
@ -13,15 +18,48 @@ class WidgetService {
WidgetService(this._repository); WidgetService(this._repository);
Future<void> writeCredentials(String serverURL, String sessionKey) async { Future<void> writeSessionKey(
String sessionKey,
) async {
await _repository.setAppGroupId(appShareGroupId); await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, serverURL);
await _repository.saveData(kWidgetAuthToken, sessionKey); await _repository.saveData(kWidgetAuthToken, sessionKey);
// wait 3 seconds to ensure the widget is updated, dont block // wait 3 seconds to ensure the widget is updated, dont block
Future.delayed(const Duration(seconds: 3), refreshWidgets); Future.delayed(const Duration(seconds: 3), refreshWidgets);
} }
Future<void> writeServerList() async {
await _repository.setAppGroupId(appShareGroupId);
// create json string from serverURLS
final serverURLSString = jsonEncode(_buildServerList());
await _repository.saveData(kWidgetServerEndpoint, serverURLSString);
Future.delayed(const Duration(seconds: 3), refreshWidgets);
}
List<String> _buildServerList() {
final List<dynamic> jsonList =
jsonDecode(Store.tryGet(StoreKey.externalEndpointList) ?? "[]");
final endpointList =
jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList();
final String? localEndpoint = Store.tryGet(StoreKey.localEndpoint);
final String? serverUrl = Store.tryGet(StoreKey.serverUrl);
final List<dynamic> serverUrlList = endpointList.map((e) => e.url).toList();
if (localEndpoint != null) {
serverUrlList.insert(0, localEndpoint);
}
if (serverUrl != null && serverUrl != localEndpoint) {
serverUrlList.insert(0, serverUrl);
}
return serverUrlList.cast<String>();
}
Future<void> clearCredentials() async { Future<void> clearCredentials() async {
await _repository.setAppGroupId(appShareGroupId); await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, ""); await _repository.saveData(kWidgetServerEndpoint, "");

View File

@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart';
class ExternalNetworkPreference extends HookConsumerWidget { class ExternalNetworkPreference extends HookConsumerWidget {
@ -35,6 +36,8 @@ class ExternalNetworkPreference extends HookConsumerWidget {
StoreKey.externalEndpointList, StoreKey.externalEndpointList,
jsonString, jsonString,
); );
ref.read(widgetServiceProvider).writeServerList();
} }
updateValidationStatus(String url, int index, AuxCheckStatus status) { updateValidationStatus(String url, int index, AuxCheckStatus status) {