diff --git a/Revolt.xcodeproj/project.pbxproj b/Revolt.xcodeproj/project.pbxproj index adf5d15..769a298 100644 --- a/Revolt.xcodeproj/project.pbxproj +++ b/Revolt.xcodeproj/project.pbxproj @@ -77,7 +77,6 @@ 1773C03D2C07DD1F007B8867 /* MessageableChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1773C03C2C07DD1F007B8867 /* MessageableChannel.swift */; }; 17772C182C30AF83000D1EDA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17772C172C30AF83000D1EDA /* AppDelegate.swift */; }; 1777DD892ADC3C31003D6C72 /* Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1777DD882ADC3C31003D6C72 /* Markdown.swift */; }; - 177DA1172CEADBB1000FC7EA /* AnyCodable in Frameworks */ = {isa = PBXBuildFile; productRef = 177DA1162CEADBB1000FC7EA /* AnyCodable */; }; 1781011E2C8CBC2900AC2756 /* SubviewAttachingTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 1781011D2C8CBC2900AC2756 /* SubviewAttachingTextView */; }; 1782F5E62B08F60B00759D40 /* Discovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1782F5E52B08F60B00759D40 /* Discovery.swift */; }; 17863A592C8094840051A52C /* Tile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17863A582C8094840051A52C /* Tile.swift */; }; @@ -141,6 +140,8 @@ 17F8B7092C7983730065F1DE /* CreateServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F8B7082C7983730065F1DE /* CreateServer.swift */; }; 17F9D7632C9208B500D0BB6F /* MessageReactionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F9D7622C9208B500D0BB6F /* MessageReactionsSheet.swift */; }; 36D461CF77D84B97B94929A9 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 03268FCCFC7D4D1F8B6E9F6F /* Sentry */; }; + 8FCDB25C2D067E7C009C82DB /* AnyCodable in Frameworks */ = {isa = PBXBuildFile; productRef = 177DA1162CEADBB1000FC7EA /* AnyCodable */; }; + 8FCDB25E2D067E8B009C82DB /* ServerUrlSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FCDB25D2D067E8B009C82DB /* ServerUrlSelector.swift */; }; D49B705329C4D3FE009494A5 /* RevoltApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49B705229C4D3FE009494A5 /* RevoltApp.swift */; }; D49B705729C4D3FE009494A5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D49B705629C4D3FE009494A5 /* Assets.xcassets */; }; D49B705A29C4D3FE009494A5 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D49B705929C4D3FE009494A5 /* Preview Assets.xcassets */; }; @@ -326,6 +327,7 @@ 17F555262AFC229900958F2F /* ServerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSettings.swift; sourceTree = ""; }; 17F8B7082C7983730065F1DE /* CreateServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateServer.swift; sourceTree = ""; }; 17F9D7622C9208B500D0BB6F /* MessageReactionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReactionsSheet.swift; sourceTree = ""; }; + 8FCDB25D2D067E8B009C82DB /* ServerUrlSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUrlSelector.swift; sourceTree = ""; }; D49B704F29C4D3FE009494A5 /* Revolt.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Revolt.app; sourceTree = BUILT_PRODUCTS_DIR; }; D49B705229C4D3FE009494A5 /* RevoltApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevoltApp.swift; sourceTree = ""; }; D49B705629C4D3FE009494A5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -352,6 +354,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 8FCDB25C2D067E7C009C82DB /* AnyCodable in Frameworks */, DAAA4BF429F2274A00F41E52 /* Collections in Frameworks */, 175997192B2FB90700C39CF6 /* Flow in Frameworks */, 1746CF5A2B83C6750051FD47 /* CodableWrapper in Frameworks */, @@ -590,6 +593,7 @@ children = ( 176485742CA3947B00AF8141 /* PermissionToggle.swift */, 170410962CA478D5002C1445 /* AllPermissionsSettings.swift */, + 8FCDB25D2D067E8B009C82DB /* ServerUrlSelector.swift */, ); path = Settings; sourceTree = ""; @@ -1112,6 +1116,7 @@ 17D5C9442B14DF500060C035 /* DMScrollView.swift in Sources */, 1746A4B62CAF57C300095CF3 /* GroupDMChannelPermissionsSettings.swift in Sources */, 17D8BACF2B211DEE005F5447 /* ChannelInfo.swift in Sources */, + 8FCDB25E2D067E8B009C82DB /* ServerUrlSelector.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Revolt/Api/UserSettingsStore.swift b/Revolt/Api/UserSettingsStore.swift index ba10f54..ce5229f 100644 --- a/Revolt/Api/UserSettingsStore.swift +++ b/Revolt/Api/UserSettingsStore.swift @@ -163,6 +163,12 @@ class PersistentUserSettingsStore: Codable { var notifications: NotificationOptionsData + var serverUrl: String { + didSet { + keyWasSet() + } + } + var lastOpenChannels: [String: String] { didSet { keyWasSet() @@ -177,8 +183,9 @@ class PersistentUserSettingsStore: Codable { var experiments: ExperimentOptionsData - init(keyWasSet: @escaping () -> Void, notifications: NotificationOptionsData, lastOpenChannels: [String: String], closedCategories: [String: Set], experiments: ExperimentOptionsData) { + init(keyWasSet: @escaping () -> Void, notifications: NotificationOptionsData, serverUrl: String, lastOpenChannels: [String: String], closedCategories: [String: Set], experiments: ExperimentOptionsData) { self.notifications = notifications + self.serverUrl = serverUrl self.lastOpenChannels = lastOpenChannels self.closedCategories = closedCategories self.experiments = experiments @@ -188,6 +195,7 @@ class PersistentUserSettingsStore: Codable { init() { self.notifications = NotificationOptionsData() + self.serverUrl = "" self.lastOpenChannels = [:] self.closedCategories = [:] self.experiments = ExperimentOptionsData() @@ -201,6 +209,7 @@ class PersistentUserSettingsStore: Codable { enum CodingKeys: String, CodingKey { case _notifications = "notifications" + case _serverUrl = "serverUrl" case _lastOpenChannels = "lastOpenChannels" case _closedCategories = "closedCategories" case _experiments = "experiments" @@ -338,9 +347,14 @@ class UserSettingsData { mfaStatus: try await state.http.fetchMFAStatus().get() ) - let settingsValues = try await state.http.fetchSettings(keys: ["notifications"]).get() - let notificationValue = try! settingsValues["notifications"].unwrapped().b.replacingOccurrences(of: #"\""#, with: #"""#) - self.cache.notificationSettings = try! JSONDecoder().decode(UserSettingsNotificationsData.self, from: try! notificationValue.data(using: .utf8).unwrapped()) + if let settingsValues = try await state.http.fetchSettings(keys: ["notifications"]).get()["notifications"] { + let notificationString = String(describing: settingsValues) + let cleanValue = notificationString.replacingOccurrences(of: #"\""#, with: #"""#) + if let data = cleanValue.data(using: String.Encoding.utf8), + let notificationSettings = try? JSONDecoder().decode(UserSettingsNotificationsData.self, from: data) { + self.cache.notificationSettings = notificationSettings + } + } self.cacheState = .cached writeCacheToFile() @@ -427,4 +441,4 @@ class UserSettingsData { self.store = .init() self.store.updateDecodeWithCallback(keyWasSet: storeKeyWasSet) } -} +} \ No newline at end of file diff --git a/Revolt/Components/Settings/ServerUrlSelector.swift b/Revolt/Components/Settings/ServerUrlSelector.swift new file mode 100644 index 0000000..db628b0 --- /dev/null +++ b/Revolt/Components/Settings/ServerUrlSelector.swift @@ -0,0 +1,211 @@ +// +// ServerUrlSelector.swift +// Revolt +// +// Created by pythcon on 12/7/24. +// + +import SwiftUI +import Types + +struct ServerUrlSelector: View { + @EnvironmentObject var viewState: ViewState + @Environment(\.colorScheme) var colorScheme + + private let officialServer = "https://api.revolt.chat" + @State private var showCustomServer = false + @State private var customDomain: String = "" + @State private var isValidating = false + @State private var validationError: String? = nil + @State private var validationSuccess: String? = nil + @State private var connectionStatus: ConnectionStatus = .untested + @FocusState private var isTextFieldFocused: Bool + + enum ConnectionStatus { + case untested + case testing + case success + case failed + } + + private func validateAndUpdateApiInfo(_ domain: String) { + if domain.isEmpty { return } + + isValidating = true + connectionStatus = .testing + validationError = nil + validationSuccess = nil + + let baseUrl: String + if domain == officialServer { + baseUrl = officialServer + } else { + // First try the domain as-is + let cleanDomain = domain.hasSuffix("/") ? String(domain.dropLast()) : domain + baseUrl = (domain.starts(with: "http://") || domain.starts(with: "https://")) ? cleanDomain : "https://" + cleanDomain + } + + // Function to try validation with a URL + func tryValidation(url: String) async -> Bool { + let tempHttp = HTTPClient(token: nil, baseURL: url) + do { + let fetchedApiInfo = try await tempHttp.fetchApiInfo().get() + // Validate that this is actually a Revolt API + guard + !fetchedApiInfo.revolt.isEmpty, // Check revolt version exists + fetchedApiInfo.features.january != nil, // Check for required features + fetchedApiInfo.features.autumn != nil, // Check for required features + !fetchedApiInfo.ws.isEmpty, // Check websocket URL exists + !fetchedApiInfo.app.isEmpty // Check app URL exists + else { + return false + } + + await MainActor.run { + viewState.apiInfo = fetchedApiInfo + viewState.userSettingsStore.store.serverUrl = url + + if url != baseUrl { + customDomain = url // Update textbox if we added /api + } + + isValidating = false + validationSuccess = "Successfully connected to server" + connectionStatus = .success + } + return true + } catch { + return false + } + } + + Task { + // Try original URL first + if await tryValidation(url: baseUrl) { + return + } + + // If not official server and first attempt failed, try with /api + if domain != officialServer { + let apiUrl = baseUrl + "/api" + if await tryValidation(url: apiUrl) { + return + } + } + + // If both attempts failed, show error + await MainActor.run { + isValidating = false + validationError = "Unable to connect to server" + connectionStatus = .failed + } + } + } + + private var statusIcon: some View { + Group { + switch connectionStatus { + case .untested: + Image(systemName: "link.circle.fill") + .foregroundStyle(.gray) + case .testing: + ProgressView() + .controlSize(.small) + case .success: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + case .failed: + Image(systemName: "x.circle.fill") + .foregroundStyle(.red) + } + } + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text("Server") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button(action: { + withAnimation { + showCustomServer.toggle() + if !showCustomServer { + validateAndUpdateApiInfo(officialServer) + viewState.userSettingsStore.store.serverUrl = officialServer + } + } + }) { + Text(showCustomServer ? "Use Official" : "Use Custom") + .font(.caption) + .foregroundStyle(viewState.theme.accent) + } + } + + if showCustomServer { + HStack { + TextField( + "Domain (e.g. example.com)", + text: $customDomain + ) + .textContentType(.URL) + .keyboardType(.URL) + .disabled(isValidating) + .focused($isTextFieldFocused) + .onChange(of: isTextFieldFocused) { oldValue, newValue in + if !newValue { + validateAndUpdateApiInfo(customDomain) + } + } + .onChange(of: customDomain) { oldValue, newValue in + print("onChange: \(oldValue) -> \(newValue)") + print("connectionStatus: \(connectionStatus)") + print("validationSuccess: \(validationSuccess)") + if oldValue != newValue { + connectionStatus = .untested + validationError = nil + validationSuccess = nil + } + } + + Button(action: { + i + ';f !customDomain.isEmpty { + validateAndUpdateApiInfo(customDomain) + } + }) { + statusIcon + } + .disabled(connectionStatus == .testing || customDomain.isEmpty) + } + .padding() + .background((colorScheme == .light) ? Color(white: 0.851) : Color(white: 0.2)) + .clipShape(.rect(cornerRadius: 5)) + + if let error = validationError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } else if let success = validationSuccess { + Text(success) + .font(.caption) + .foregroundStyle(.green) + } + } else { + Text("Official Revolt Server") + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background((colorScheme == .light) ? Color(white: 0.851) : Color(white: 0.2)) + .clipShape(.rect(cornerRadius: 5)) + } + } + .padding(.bottom) + .onAppear { + if viewState.userSettingsStore.store.serverUrl.isEmpty { + viewState.userSettingsStore.store.serverUrl = officialServer + validateAndUpdateApiInfo(officialServer) + } + } + } +} diff --git a/Revolt/Pages/Login/CreateAccount.swift b/Revolt/Pages/Login/CreateAccount.swift index e9db877..07e1153 100644 --- a/Revolt/Pages/Login/CreateAccount.swift +++ b/Revolt/Pages/Login/CreateAccount.swift @@ -54,6 +54,8 @@ struct CreateAccount: View { // Text(verbatim: error) // .foregroundStyle(.red) // } + ServerUrlSelector() + TextField( "Email", text: $email diff --git a/Revolt/Pages/Login/Login.swift b/Revolt/Pages/Login/Login.swift index 745ff3d..703d4f3 100644 --- a/Revolt/Pages/Login/Login.swift +++ b/Revolt/Pages/Login/Login.swift @@ -58,6 +58,8 @@ struct LogIn: View { .foregroundStyle((colorScheme == .light) ? Color.black : Color.white) Group { + ServerUrlSelector() + if let error = errorMessage { Text(verbatim: error) .foregroundStyle(.red) diff --git a/Revolt/Pages/Settings/UserSettings.swift b/Revolt/Pages/Settings/UserSettings.swift index 7565240..476d2e0 100644 --- a/Revolt/Pages/Settings/UserSettings.swift +++ b/Revolt/Pages/Settings/UserSettings.swift @@ -870,6 +870,16 @@ struct UserSettings: View { var body: some View { List { Section("Account Info") { + HStack { + Text("Server") + Spacer() + if let apiInfo = viewState.apiInfo { + Text(URL(string: apiInfo.app)?.host ?? "") + .foregroundStyle(.secondary) + } + } + .listRowBackground(viewState.theme.background2) + Button(action: { presentChangeUsernameSheet = true }) { diff --git a/Revolt/RevoltApp.swift b/Revolt/RevoltApp.swift index 8377542..1738918 100644 --- a/Revolt/RevoltApp.swift +++ b/Revolt/RevoltApp.swift @@ -12,7 +12,8 @@ struct RevoltApp: App { #endif @Environment(\.locale) var systemLocale: Locale - @StateObject var state = ViewState.shared ?? ViewState() + @StateObject var state = ViewState() + @Environment(\.scenePhase) var scenePhase init() { if !isPreview { @@ -35,6 +36,12 @@ struct RevoltApp: App { .background(state.theme.background.color) .foregroundStyle(state.theme.foreground.color) .typesettingLanguage((state.currentLocale ?? systemLocale).language) + .onAppear { + // Remove this - let the saved URL persist + // if state.userSettingsStore.store.serverUrl.isEmpty { + // state.userSettingsStore.store.serverUrl = "api.revolt.chat" + // } + } .onOpenURL { url in print(url) let components = NSURLComponents(string: url.absoluteString) @@ -190,15 +197,25 @@ struct InnerApp: View { case .connecting: VStack { Text("Connecting...") -#if DEBUG + #if DEBUG Button { - viewState.destroyCache() - viewState.sessionToken = nil - viewState.state = .signedOut + withAnimation { + viewState.destroyCache() + viewState.sessionToken = nil + viewState.state = .signedOut + viewState.isOnboarding = false + viewState.forceMainScreen = false + } } label: { Text("Developer: Nuke everything and force welcome screen") + .padding() + .background(viewState.theme.background2) + .foregroundStyle(viewState.theme.accent) + .clipShape(RoundedRectangle(cornerRadius: 8)) } -#endif + .buttonStyle(.plain) + .padding(.top) + #endif } case .connected: MainApp() diff --git a/Revolt/ViewState.swift b/Revolt/ViewState.swift index 1367f02..be68f31 100644 --- a/Revolt/ViewState.swift +++ b/Revolt/ViewState.swift @@ -134,7 +134,7 @@ public class ViewState: ObservableObject { #endif let keychain = Keychain(service: "chat.revolt.app") - var http: HTTPClient = HTTPClient(token: nil, baseURL: "https://app.revolt.chat/api") + var http: HTTPClient = HTTPClient(token: nil, baseURL: "") var launchTransaction: any Sentry.Span @Published var ws: WebSocketStream? = nil @@ -144,6 +144,9 @@ public class ViewState: ObservableObject { let apiInfo = apiInfo DispatchQueue.global(qos: .background).async { UserDefaults.standard.set(try! JSONEncoder().encode(apiInfo), forKey: "apiInfo") + if let apiInfo = apiInfo { + self.http = HTTPClient(token: self.http.token, baseURL: apiInfo.app + "/api") + } } } } @@ -290,6 +293,16 @@ public class ViewState: ObservableObject { launchTransaction = SentrySDK.startTransaction(name: "launch", operation: "launch") let decoder = JSONDecoder() + // Load stored settings + let settings = UserSettingsData.maybeRead(viewState: nil) + + // Only create HTTP client if we have a server URL + if !settings.store.serverUrl.isEmpty { + self.http = HTTPClient(token: nil, baseURL: "\(settings.store.serverUrl)") + } else { + self.http = HTTPClient(token: nil, baseURL: "") + } + self.apiInfo = ViewState.decodeUserDefaults(forKey: "apiInfo", withDecoder: decoder, defaultingTo: nil) self.userSettingsStore = UserSettingsData.maybeRead(viewState: nil) @@ -371,12 +384,19 @@ public class ViewState: ObservableObject { } func signInWithVerify(code: String, email: String, password: String) async -> Bool { + guard let baseUrl = apiInfo?.app else { + return false + } + + // Update HTTP client with current server URL + self.http = HTTPClient(token: nil, baseURL: baseUrl) + do { _ = try await self.http.createAccount_VerificationCode(code: code).get() } catch { return false } - + await signIn(email: email, password: password, callback: {a in print(String(describing: a))}) // awful workaround for the verification endpoint returning invalid session tokens return true @@ -389,13 +409,31 @@ public class ViewState: ObservableObject { } func signIn(email: String, password: String, callback: @escaping((LoginState) -> ())) async { - let body = ["email": email, "password": password, "friendly_name": "Revolt IOS"] - - await innerSignIn(body, callback) + // First fetch API info + let baseUrl = apiInfo?.app + + do { + let fetchedApiInfo = try await http.fetchApiInfo().get() + self.apiInfo = fetchedApiInfo + self.http.apiInfo = fetchedApiInfo + self.http = HTTPClient(token: nil, baseURL: fetchedApiInfo.app + "/api") + + // Now proceed with login + let body = ["email": email, "password": password, "friendly_name": "Revolt IOS"] + await innerSignIn(body, callback) + } catch { + callback(.Invalid) + return + } } private func innerSignIn(_ body: [String: Any], _ callback: @escaping((LoginState) -> ())) async { - AF.request("\(http.baseURL)/auth/session/login", method: .post, parameters: body, encoding: JSONEncoding.default) + guard let baseUrl = apiInfo?.app else { + return callback(.Invalid) + } + + let loginUrl = "\(baseUrl)/api/auth/session/login" + AF.request(loginUrl, method: .post, parameters: body, encoding: JSONEncoding.default) .responseData { response in switch response.result { @@ -450,6 +488,7 @@ public class ViewState: ObservableObject { withAnimation { state = .signedOut + userSettingsStore.store.serverUrl = "" // Clear server URL on sign out } // IMPORTANT: do not destroy the cache/session here. It'll cause the app to crash before it can transition to the welcome screen. @@ -462,6 +501,7 @@ public class ViewState: ObservableObject { func setSignedOutState() { withAnimation { state = .signedOut + userSettingsStore.store.serverUrl = "" // Clear server URL on sign out } } @@ -975,4 +1015,3 @@ extension Channel { } } } -