mirror of
https://github.com/sudoxnym/audiobookshelf-atv.git
synced 2026-04-14 11:36:27 +00:00
Update iOS ApiClient to handle token refresh
This commit is contained in:
parent
bc927d4c35
commit
5766c49f61
4 changed files with 393 additions and 22 deletions
|
|
@ -404,6 +404,7 @@ class ApiHandler(var ctx:Context) {
|
|||
errorObj.put("error", "Authentication failed - please login again")
|
||||
callback(errorObj)
|
||||
|
||||
// TODO: Notify webview frontend
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
|
||||
val errorObj = JSObject()
|
||||
|
|
|
|||
|
|
@ -53,6 +53,15 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||
private let logger = AppLogger(category: "AbsDatabase")
|
||||
private let secureStorage = SecureStorage()
|
||||
|
||||
// Used to notify the webview frontend that the token has been refreshed
|
||||
static var tokenRefreshCallback: ((String, [String: Any]) -> Void)?
|
||||
|
||||
override public func load() {
|
||||
AbsDatabase.tokenRefreshCallback = { [weak self] eventName, data in
|
||||
self?.notifyListeners(eventName, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) {
|
||||
var id = call.getString("id")
|
||||
let address = call.getString("address", "")
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import Alamofire
|
|||
|
||||
class ApiClient {
|
||||
private static let logger = AppLogger(category: "ApiClient")
|
||||
private static let secureStorage = SecureStorage()
|
||||
|
||||
public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
|
||||
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
|
||||
|
|
@ -146,6 +147,297 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Token Refresh Handling
|
||||
|
||||
/**
|
||||
* Handles token refresh when a 401 Unauthorized response is received
|
||||
* This function will:
|
||||
* 1. Get the refresh token from secure storage for the current server connection
|
||||
* 2. Make a request to /auth/refresh endpoint with the refresh token
|
||||
* 3. Update the connection config with the new accessToken and put the refreshToken in secure storage
|
||||
* 4. Retry the original request with the new access token
|
||||
* 5. If refresh fails, handle logout
|
||||
*/
|
||||
private static func handleTokenRefresh<T: Decodable>(originalRequest: DataRequest, endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, callback: ((_ param: T?) -> Void)?) {
|
||||
guard let serverConfig = Store.serverConfig else {
|
||||
logger.error("handleTokenRefresh: No server config available")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("handleTokenRefresh: Attempting to refresh auth tokens for server \(serverConfig.name)")
|
||||
|
||||
// Get refresh token from secure storage
|
||||
guard let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConfig.id) else {
|
||||
logger.error("handleTokenRefresh: No refresh token available for server \(serverConfig.name)")
|
||||
handleRefreshFailure()
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
||||
|
||||
// Create refresh token request
|
||||
let refreshHeaders: HTTPHeaders = [
|
||||
"x-refresh-token": refreshToken,
|
||||
"Content-Type": "application/json"
|
||||
]
|
||||
|
||||
let refreshRequest = AF.request("\(serverConfig.address)/auth/refresh", method: .post, headers: refreshHeaders)
|
||||
|
||||
refreshRequest.responseDecodable(of: RefreshResponse.self) { response in
|
||||
switch response.result {
|
||||
case .success(let refreshResponse):
|
||||
guard let user = refreshResponse.user,
|
||||
!user.accessToken.isEmpty else {
|
||||
logger.error("handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
|
||||
handleRefreshFailure()
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("handleTokenRefresh: Successfully obtained new access token")
|
||||
|
||||
// Update tokens in secure storage and store
|
||||
updateTokens(newAccessToken: user.accessToken, newRefreshToken: user.refreshToken ?? refreshToken, serverConnectionConfigId: serverConfig.id)
|
||||
|
||||
// Retry the original request with the new access token
|
||||
logger.log("handleTokenRefresh: Retrying original request with new token")
|
||||
retryOriginalRequest(endpoint: endpoint, method: method, parameters: parameters, decodable: decodable, newAccessToken: user.accessToken, callback: callback)
|
||||
|
||||
case .failure(let error):
|
||||
logger.error("handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
|
||||
handleRefreshFailure()
|
||||
callback?(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the stored tokens with new access and refresh tokens
|
||||
*/
|
||||
private static func updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) {
|
||||
// Update the refresh token in secure storage if it's new
|
||||
if newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) {
|
||||
let hasStored = secureStorage.storeRefreshToken(serverConnectionConfigId: serverConnectionConfigId, refreshToken: newRefreshToken)
|
||||
logger.log("updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)")
|
||||
}
|
||||
|
||||
// Update access token on server connection config
|
||||
Database.shared.updateServerConnectionConfigToken(newToken: newAccessToken)
|
||||
logger.log("updateTokens: Updated access token in server connection config")
|
||||
|
||||
logger.log("updateTokens: Successfully refreshed auth tokens for server \(Store.serverConfig?.name ?? "unknown")")
|
||||
|
||||
// Notify webview frontend about token refresh
|
||||
if let callback = AbsDatabase.tokenRefreshCallback {
|
||||
let tokenData: [String: Any] = ["accessToken": newAccessToken]
|
||||
callback("onTokenRefresh", tokenData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries the original request with the new access token
|
||||
*/
|
||||
private static func retryOriginalRequest<T: Decodable>(endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, newAccessToken: String, callback: ((_ param: T?) -> Void)?) {
|
||||
guard let serverConfig = Store.serverConfig else {
|
||||
logger.error("retryOriginalRequest: No server config available")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let headers: HTTPHeaders = [
|
||||
"Authorization": "Bearer \(newAccessToken)"
|
||||
]
|
||||
|
||||
let retryRequest: DataRequest
|
||||
|
||||
switch method {
|
||||
case .get:
|
||||
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .get, headers: headers)
|
||||
case .post:
|
||||
if let parameters = parameters as? [String: Any] {
|
||||
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: headers)
|
||||
} else if let encodableParams = parameters as? Encodable {
|
||||
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, parameters: encodableParams, encoder: JSONParameterEncoder.default, headers: headers)
|
||||
} else {
|
||||
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, headers: headers)
|
||||
}
|
||||
case .patch:
|
||||
if let encodableParams = parameters as? Encodable {
|
||||
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, parameters: encodableParams, encoder: JSONParameterEncoder.default, headers: headers)
|
||||
} else {
|
||||
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, headers: headers)
|
||||
}
|
||||
default:
|
||||
logger.error("retryOriginalRequest: Unsupported method \(method)")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
retryRequest.responseDecodable(of: decodable) { response in
|
||||
switch response.result {
|
||||
case .success(let obj):
|
||||
callback?(obj)
|
||||
case .failure(let error):
|
||||
logger.error("retryOriginalRequest: Retry request failed: \(error)")
|
||||
callback?(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the case when token refresh fails
|
||||
* This will clear the current server connection and notify webview
|
||||
*/
|
||||
private static func handleRefreshFailure() {
|
||||
logger.log("handleRefreshFailure: Token refresh failed, clearing session")
|
||||
|
||||
// Clear the current server connection
|
||||
Store.serverConfig = nil
|
||||
|
||||
// Remove refresh token from secure storage
|
||||
if let serverConfig = Store.serverConfig {
|
||||
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConfig.id)
|
||||
}
|
||||
|
||||
// Notify webview frontend about token refresh failure
|
||||
if let callback = AbsDatabase.tokenRefreshCallback {
|
||||
callback("onTokenRefreshFailure", ["error": "Token refresh failed"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enhanced API Methods with Token Refresh
|
||||
|
||||
public static func getResourceWithTokenRefresh<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let headers: HTTPHeaders = [
|
||||
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||
]
|
||||
|
||||
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .get, headers: headers)
|
||||
|
||||
request.responseDecodable(of: decodable) { response in
|
||||
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||
logger.log("getResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .get, parameters: nil, decodable: decodable, callback: callback)
|
||||
} else {
|
||||
switch response.result {
|
||||
case .success(let obj):
|
||||
callback?(obj)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
print(error)
|
||||
callback?(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func postResourceWithTokenRefresh<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U?) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let headers: HTTPHeaders = [
|
||||
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||
]
|
||||
|
||||
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
|
||||
|
||||
request.responseDecodable(of: decodable) { response in
|
||||
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: decodable, callback: callback)
|
||||
} else {
|
||||
switch response.result {
|
||||
case .success(let obj):
|
||||
callback?(obj)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
print(error)
|
||||
callback?(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request for endpoints that only return success/failure
|
||||
*/
|
||||
public static func postResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
callback?(false)
|
||||
return
|
||||
}
|
||||
|
||||
let headers: HTTPHeaders = [
|
||||
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||
]
|
||||
|
||||
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
|
||||
|
||||
request.response { response in
|
||||
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: EmptyResponse.self) { result in
|
||||
callback?(result != nil)
|
||||
}
|
||||
} else {
|
||||
switch response.result {
|
||||
case .success(_):
|
||||
callback?(true)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
print(error)
|
||||
callback?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func patchResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
callback?(false)
|
||||
return
|
||||
}
|
||||
|
||||
let headers: HTTPHeaders = [
|
||||
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||
]
|
||||
|
||||
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .patch, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
|
||||
|
||||
request.response { response in
|
||||
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||
logger.log("patchResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .patch, parameters: parameters, decodable: EmptyResponse.self) { result in
|
||||
callback?(result != nil)
|
||||
}
|
||||
} else {
|
||||
switch response.result {
|
||||
case .success(_):
|
||||
callback?(true)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
print(error)
|
||||
callback?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Functions
|
||||
|
||||
public static func startPlaybackSession(libraryItemId: String, episodeId: String?, forceTranscode:Bool, callback: @escaping (_ param: PlaybackSession) -> Void) {
|
||||
var endpoint = "api/items/\(libraryItemId)/play"
|
||||
if episodeId != nil {
|
||||
|
|
@ -160,20 +452,28 @@ class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
let parameters: [String: Any] = [
|
||||
"forceDirectPlay": !forceTranscode ? "1" : "",
|
||||
"forceTranscode": forceTranscode ? "1" : "",
|
||||
"mediaPlayer": "AVPlayer",
|
||||
"deviceInfo": [
|
||||
"deviceId": UIDevice.current.identifierForVendor?.uuidString,
|
||||
"manufacturer": "Apple",
|
||||
"model": modelCode,
|
||||
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
]
|
||||
]
|
||||
ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in
|
||||
let session = obj
|
||||
// Create an Encodable struct for the parameters
|
||||
let parameters = PlaybackSessionRequest(
|
||||
forceDirectPlay: !forceTranscode ? "1" : "",
|
||||
forceTranscode: forceTranscode ? "1" : "",
|
||||
mediaPlayer: "AVPlayer",
|
||||
deviceInfo: DeviceInfo(
|
||||
deviceId: UIDevice.current.identifierForVendor?.uuidString,
|
||||
manufacturer: "Apple",
|
||||
model: modelCode,
|
||||
clientVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
)
|
||||
)
|
||||
|
||||
// Use the new token refresh-enabled method
|
||||
postResourceWithTokenRefresh(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { session in
|
||||
guard let session = session else {
|
||||
logger.error("startPlaybackSession: Failed to create playback session")
|
||||
callback(PlaybackSession()) // Return empty session on failure
|
||||
return
|
||||
}
|
||||
|
||||
// Set server connection info on the session
|
||||
session.serverConnectionConfigId = Store.serverConfig!.id
|
||||
session.serverAddress = Store.serverConfig!.address
|
||||
|
||||
|
|
@ -182,15 +482,28 @@ class ApiClient {
|
|||
}
|
||||
|
||||
public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) async -> Bool {
|
||||
return await postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report)
|
||||
return await withCheckedContinuation { continuation in
|
||||
postResourceWithTokenRefresh(endpoint: "api/session/\(sessionId)/sync", parameters: report) { success in
|
||||
continuation.resume(returning: success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
|
||||
return await postResource(endpoint: "api/session/local", parameters: session)
|
||||
return await withCheckedContinuation { continuation in
|
||||
postResourceWithTokenRefresh(endpoint: "api/session/local", parameters: session) { success in
|
||||
continuation.resume(returning: success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
|
||||
return await postResource(endpoint: "api/session/local-all", parameters: LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo))
|
||||
return await withCheckedContinuation { continuation in
|
||||
let payload = LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo)
|
||||
postResourceWithTokenRefresh(endpoint: "api/session/local-all", parameters: payload) { success in
|
||||
continuation.resume(returning: success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func syncLocalSessionsWithServer(isFirstSync: Bool) async {
|
||||
|
|
@ -257,7 +570,7 @@ class ApiClient {
|
|||
public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) {
|
||||
logger.log("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
|
||||
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
|
||||
patchResource(endpoint: endpoint, parameters: payload) { success in
|
||||
patchResourceWithTokenRefresh(endpoint: endpoint, parameters: payload) { _ in
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
|
@ -265,21 +578,29 @@ class ApiClient {
|
|||
public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? {
|
||||
logger.log("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")")
|
||||
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
|
||||
return await getResource(endpoint: endpoint, decodable: MediaProgress.self)
|
||||
return await withCheckedContinuation { continuation in
|
||||
getResourceWithTokenRefresh(endpoint: endpoint, decodable: MediaProgress.self) { result in
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func getCurrentUser() async -> User? {
|
||||
logger.log("getCurrentUser")
|
||||
return await getResource(endpoint: "api/me", decodable: User.self)
|
||||
return await withCheckedContinuation { continuation in
|
||||
getResourceWithTokenRefresh(endpoint: "api/me", decodable: User.self) { result in
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
|
||||
public static func getLibraryItemWithProgress(libraryItemId: String, episodeId: String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
|
||||
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
|
||||
if episodeId != nil {
|
||||
endpoint += "&episodeId=\(episodeId!)"
|
||||
}
|
||||
|
||||
ApiClient.getResource(endpoint: endpoint, decodable: LibraryItem.self) { obj in
|
||||
|
||||
getResourceWithTokenRefresh(endpoint: endpoint, decodable: LibraryItem.self) { obj in
|
||||
callback(obj)
|
||||
}
|
||||
}
|
||||
|
|
@ -338,3 +659,30 @@ struct Connectivity {
|
|||
return self.sharedInstance.isReachable
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Response Models
|
||||
|
||||
struct RefreshResponse: Decodable {
|
||||
let user: RefreshUser?
|
||||
}
|
||||
|
||||
struct RefreshUser: Decodable {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
}
|
||||
|
||||
struct EmptyResponse: Decodable {}
|
||||
|
||||
struct PlaybackSessionRequest: Encodable {
|
||||
let forceDirectPlay: String
|
||||
let forceTranscode: String
|
||||
let mediaPlayer: String
|
||||
let deviceInfo: DeviceInfo
|
||||
}
|
||||
|
||||
struct DeviceInfo: Encodable {
|
||||
let deviceId: String?
|
||||
let manufacturer: String
|
||||
let model: String?
|
||||
let clientVersion: String?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,19 @@ class Database {
|
|||
setLastActiveConfigIndex(index: config.index)
|
||||
}
|
||||
}
|
||||
|
||||
public func updateServerConnectionConfigToken(newToken: String) {
|
||||
do {
|
||||
let realm = try Realm()
|
||||
if let config = realm.objects(ServerConnectionConfig.self).first(where: { $0.index == getLastActiveConfigIndex() }) {
|
||||
try realm.write {
|
||||
config.token = newToken
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
debugPrint("Failed to update server connection config token: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteServerConnectionConfig(id: String) {
|
||||
let realm = try! Realm()
|
||||
|
|
|
|||
Loading…
Reference in a new issue