Compare commits

...

5 Commits

Author SHA1 Message Date
bwees
97329d488b chore: code review changes from @shenlong-tanwen 2025-06-24 09:18:11 -05:00
bwees
c6cd4dce78 fix: include port in baseURL 2025-06-24 09:17:42 -05:00
bwees
0a90f490f2 cleanly migrate the server URL value to a JSON array (ie coming from 1.135.0 app) 2025-06-23 17:55:41 -05:00
bwees
88e008c4c2 fix: remove duplicate endpoints 2025-06-23 17:44:51 -05:00
bwees
99a9914da2 feat: ios widget supports alternate server URLs 2025-06-23 17:41:55 -05:00
6 changed files with 138 additions and 31 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
@@ -57,7 +65,7 @@ class ImmichAPI {
init() async throws { init() async throws {
// fetch the credentials from the UserDefaults store that dart placed here // fetch the credentials from the UserDefaults store that dart placed here
guard let defaults = UserDefaults(suiteName: "group.app.immich.share"), guard let defaults = UserDefaults(suiteName: "group.app.immich.share"),
let serverURL = defaults.string(forKey: "widget_server_url"), var serverURL = defaults.string(forKey: "widget_server_url"),
let sessionKey = defaults.string(forKey: "widget_auth_token") let sessionKey = defaults.string(forKey: "widget_auth_token")
else { else {
throw WidgetError.noLogin throw WidgetError.noLogin
@@ -66,13 +74,55 @@ class ImmichAPI {
if serverURL == "" || sessionKey == "" { if serverURL == "" || sessionKey == "" {
throw WidgetError.noLogin throw WidgetError.noLogin
} }
serverConfig = ServerConfig( // migrate the server list value to a JSON array if it is not already
serverEndpoint: serverURL, if !serverURL.starts(with: "[") {
sessionKey: sessionKey let newServerList = "[\"\(serverURL)\"]"
) defaults.set(newServerList, forKey: "widget_server_url")
serverURL = newServerList
}
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
} }
private static func validateServer(at endpointURL: URL) async -> URL? {
// build a URL that is only scheme, host, and port
var components = URLComponents()
components.scheme = endpointURL.scheme
components.host = endpointURL.host
components.port = endpointURL.port
guard let baseURL = components.url else { return nil }
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,31 +1,68 @@
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/repositories/auth.repository.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) {
return WidgetService( return WidgetService(
ref.watch(widgetRepositoryProvider), ref.watch(widgetRepositoryProvider),
ref.watch(authRepositoryProvider),
); );
}); });
class WidgetService { class WidgetService {
final WidgetRepository _repository; final WidgetRepository _widgetRepository;
final AuthRepository _authRepository;
WidgetService(this._repository); WidgetService(this._widgetRepository, this._authRepository);
Future<void> writeCredentials(String serverURL, String sessionKey) async { Future<void> writeSessionKey(
await _repository.setAppGroupId(appShareGroupId); String sessionKey,
await _repository.saveData(kWidgetServerEndpoint, serverURL); ) async {
await _repository.saveData(kWidgetAuthToken, sessionKey); await _widgetRepository.setAppGroupId(appShareGroupId);
await _widgetRepository.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 _widgetRepository.setAppGroupId(appShareGroupId);
// create json string from serverURLS
final serverURLSString = jsonEncode(_buildServerList());
await _widgetRepository.saveData(kWidgetServerEndpoint, serverURLSString);
Future.delayed(const Duration(seconds: 3), refreshWidgets);
}
List<String> _buildServerList() {
final endpointList = _authRepository.getExternalEndpointList();
final String? localEndpoint = Store.tryGet(StoreKey.localEndpoint);
final String? serverUrl = Store.tryGet(StoreKey.serverUrl);
final List<String> serverUrlList = endpointList.map((e) => e.url).toList();
if (localEndpoint != null) {
serverUrlList.insert(0, localEndpoint);
}
if (serverUrl != null) {
serverUrlList.insert(0, serverUrl);
}
return serverUrlList.toSet().toList();
}
Future<void> clearCredentials() async { Future<void> clearCredentials() async {
await _repository.setAppGroupId(appShareGroupId); await _widgetRepository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, ""); await _widgetRepository.saveData(kWidgetServerEndpoint, "");
await _repository.saveData(kWidgetAuthToken, ""); await _widgetRepository.saveData(kWidgetAuthToken, "");
// 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);
@@ -33,7 +70,7 @@ class WidgetService {
Future<void> refreshWidgets() async { Future<void> refreshWidgets() async {
for (final name in kWidgetNames) { for (final name in kWidgetNames) {
await _repository.refresh(name); await _widgetRepository.refresh(name);
} }
} }
} }

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) {