diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 70eb26329..60589cf0c 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -319,6 +319,17 @@ func setUserSMPServers(smpServers: [String]) async throws { try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers)) } +func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> { + let r = await chatSendCmd(.testSMPServer(smpServer: smpServer)) + if case let .sMPTestResult(testFailure) = r { + if let t = testFailure { + return .failure(t) + } + return .success(()) + } + throw r +} + func getChatItemTTL() throws -> ChatItemTTL { let r = chatSendCmdSync(.apiGetChatItemTTL) if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift index 0ab7a14e1..c4ba096fa 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift @@ -40,6 +40,13 @@ struct NetworkAndServers: View { Text("SMP servers") } + NavigationLink { + SMPServersView() + .navigationTitle("Your SMP servers") + } label: { + Text("SMP servers (new)") + } + Picker("Use .onion hosts", selection: $onionHosts) { ForEach(OnionHosts.values, id: \.self) { Text($0.text) } } diff --git a/apps/ios/Shared/Views/UserSettings/SMPServerView.swift b/apps/ios/Shared/Views/UserSettings/SMPServerView.swift new file mode 100644 index 000000000..66043fe3c --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SMPServerView.swift @@ -0,0 +1,105 @@ +// +// SMPServerView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 15/11/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SMPServerView: View { + @State var server: ServerCfg + + var body: some View { + if server.preset { + presetServer() + } else { + customServer() + } + } + + private func presetServer() -> some View { + return VStack { + List { + Section("Preset server address") { + Text(server.server) + } + useServerSection() + } + } + } + + private func customServer() -> some View { + VStack { + List { + Section("Your server address") { + TextEditor(text: $server.server) + .multilineTextAlignment(.leading) + .autocorrectionDisabled(true) + .autocapitalization(.none) + .lineLimit(10) + .frame(height: 108) + .padding(-6) + } + useServerSection() + Section("Add to another device") { + QRCode(uri: server.server) + .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) + } + } + } + } + + private func useServerSection() -> some View { + Section("Use server") { + HStack { + Button("Test server") { + Task { await testServerConnection(server: $server) } + } + Spacer() + showTestStatus(server: server) + } + Toggle("Enabled", isOn: $server.enabled) + Button("Remove server", role: .destructive) { + + } + } + } +} + +@ViewBuilder func showTestStatus(server: ServerCfg) -> some View { + switch server.tested { + case .some(true): + Image(systemName: "checkmark") + .foregroundColor(.green) + case .some(false): + Image(systemName: "multiply") + .foregroundColor(.red) + case .none: + Color.clear + } +} + +func testServerConnection(server: Binding) async { + do { + let r = try await testSMPServer(smpServer: server.wrappedValue.server) + await MainActor.run { + switch r { + case .success: server.wrappedValue.tested = true + case .failure: server.wrappedValue.tested = false + } + } + } catch let error { + await MainActor.run { + server.wrappedValue.tested = false + } + } +} + +struct SMPServerView_Previews: PreviewProvider { + static var previews: some View { + SMPServerView(server: ServerCfg.sampleData.custom) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SMPServersView.swift b/apps/ios/Shared/Views/UserSettings/SMPServersView.swift new file mode 100644 index 000000000..fb4cd2f1e --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SMPServersView.swift @@ -0,0 +1,106 @@ +// +// SMPServersView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 15/11/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SMPServersView: View { + @Environment(\.editMode) var editMode + @State var servers: [ServerCfg] = [ + ServerCfg.sampleData.preset, + ServerCfg.sampleData.custom, + ServerCfg.sampleData.untested, + ] + @State var showAddServer = false + @State var showSaveAlert = false + + var body: some View { + List { + Section("SMP servers") { + ForEach(servers) { srv in + smpServerView(srv) + } + .onMove { indexSet, offset in + servers.move(fromOffsets: indexSet, toOffset: offset) + } + .onDelete { indexSet in + servers.remove(atOffsets: indexSet) + } + if isEditing { + Button("Add server…") { + showAddServer = true + } + } + } + } + .onChange(of: isEditing) { value in + if value == false { + showSaveAlert = true + } + } + .toolbar { EditButton() } + .confirmationDialog("Add server…", isPresented: $showAddServer, titleVisibility: .hidden) { + Button("Scan server QR code") { + } + Button("Add preset servers") { + } + Button("Enter server manually") { + servers.append(ServerCfg.empty) + } + } + .confirmationDialog("Save servers?", isPresented: $showSaveAlert, titleVisibility: .visible) { + Button("Test & save servers") { + for i in 0.. some View { + NavigationLink { + SMPServerView(server: srv) + .navigationBarTitle("Server") + .navigationBarTitleDisplayMode(.large) + } label: { + let v = Text(srv.server) + HStack { + showTestStatus(server: srv) + .frame(width: 16, alignment: .center) + .padding(.trailing, 4) + if srv.enabled { + v + } else { + (v + Text(" (disabled)")).foregroundColor(.secondary) + } + } + } + } +} + +struct SMPServersView_Previews: PreviewProvider { + static var previews: some View { + SMPServersView() + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index b317b6092..a6981fd9f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -53,6 +53,13 @@ 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; + 5C93292F29239A170090FFF9 /* SMPServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* SMPServersView.swift */; }; + 5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* SMPServerView.swift */; }; + 5C93293729241CDA0090FFF9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293229241CD90090FFF9 /* libffi.a */; }; + 5C93293829241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */; }; + 5C93293929241CDA0090FFF9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293429241CD90090FFF9 /* libgmpxx.a */; }; + 5C93293A29241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */; }; + 5C93293B29241CDA0090FFF9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293629241CDA0090FFF9 /* libgmp.a */; }; 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; 5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; }; @@ -122,11 +129,6 @@ 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; - 64328569291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */; }; - 6432856A291CDEF200FBE5C8 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328565291CDEF200FBE5C8 /* libgmpxx.a */; }; - 6432856B291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */; }; - 6432856C291CDEF200FBE5C8 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328567291CDEF200FBE5C8 /* libffi.a */; }; - 6432856D291CDEF200FBE5C8 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328568291CDEF200FBE5C8 /* libgmp.a */; }; 6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; }; 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; }; 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; }; @@ -250,6 +252,13 @@ 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; + 5C93292E29239A170090FFF9 /* SMPServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServersView.swift; sourceTree = ""; }; + 5C93293029239BED0090FFF9 /* SMPServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServerView.swift; sourceTree = ""; }; + 5C93293229241CD90090FFF9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a"; sourceTree = ""; }; + 5C93293429241CD90090FFF9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a"; sourceTree = ""; }; + 5C93293629241CDA0090FFF9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; }; 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = ""; }; 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNotificationsMode.swift; sourceTree = ""; }; @@ -323,11 +332,6 @@ 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; - 64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a"; sourceTree = ""; }; - 64328565291CDEF200FBE5C8 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a"; sourceTree = ""; }; - 64328567291CDEF200FBE5C8 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64328568291CDEF200FBE5C8 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = ""; }; 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = ""; }; 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = ""; }; @@ -378,13 +382,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6432856A291CDEF200FBE5C8 /* libgmpxx.a in Frameworks */, - 6432856D291CDEF200FBE5C8 /* libgmp.a in Frameworks */, - 6432856C291CDEF200FBE5C8 /* libffi.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + 5C93293B29241CDA0090FFF9 /* libgmp.a in Frameworks */, + 5C93293929241CDA0090FFF9 /* libgmpxx.a in Frameworks */, + 5C93293729241CDA0090FFF9 /* libffi.a in Frameworks */, + 5C93293A29241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a in Frameworks */, + 5C93293829241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 64328569291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a in Frameworks */, - 6432856B291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -440,11 +444,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 64328567291CDEF200FBE5C8 /* libffi.a */, - 64328568291CDEF200FBE5C8 /* libgmp.a */, - 64328565291CDEF200FBE5C8 /* libgmpxx.a */, - 64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */, - 64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */, + 5C93293229241CD90090FFF9 /* libffi.a */, + 5C93293629241CDA0090FFF9 /* libgmp.a */, + 5C93293429241CD90090FFF9 /* libgmpxx.a */, + 5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */, + 5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */, ); path = Libraries; sourceTree = ""; @@ -585,6 +589,8 @@ 5CB924E027A867BA00ACCCDD /* UserProfile.swift */, 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, 640F50E227CF991C001E05C2 /* SMPServers.swift */, + 5C93292E29239A170090FFF9 /* SMPServersView.swift */, + 5C93293029239BED0090FFF9 /* SMPServerView.swift */, 5CB2084E28DA4B4800D024EC /* RTCServers.swift */, 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */, 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */, @@ -891,12 +897,14 @@ 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, + 5C93292F29239A170090FFF9 /* SMPServersView.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */, 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, + 5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */, 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 86c1928fd..656953019 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -48,6 +48,7 @@ public enum ChatCommand { case apiGetGroupLink(groupId: Int64) case getUserSMPServers case setUserSMPServers(smpServers: [String]) + case testSMPServer(smpServer: String) case apiSetChatItemTTL(seconds: Int64?) case apiGetChatItemTTL case apiSetNetworkConfig(networkConfig: NetCfg) @@ -124,8 +125,9 @@ public enum ChatCommand { case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)" case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" - case .getUserSMPServers: return "/smp_servers" - case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" + case .getUserSMPServers: return "/smp" + case let .setUserSMPServers(smpServers): return "/smp \(smpServersStr(smpServers: smpServers))" + case let .testSMPServer(smpServer): return "/smp test \(smpServer)" case let .apiSetChatItemTTL(seconds): return "/_ttl \(chatItemTTLStr(seconds: seconds))" case .apiGetChatItemTTL: return "/ttl" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" @@ -203,6 +205,7 @@ public enum ChatCommand { case .apiGetGroupLink: return "apiGetGroupLink" case .getUserSMPServers: return "getUserSMPServers" case .setUserSMPServers: return "setUserSMPServers" + case .testSMPServer: return "testSMPServer" case .apiSetChatItemTTL: return "apiSetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL" case .apiSetNetworkConfig: return "apiSetNetworkConfig" @@ -289,6 +292,7 @@ public enum ChatResponse: Decodable, Error { case apiChats(chats: [ChatData]) case apiChat(chat: ChatData) case userSMPServers(smpServers: [String]) + case sMPTestResult(smpTestFailure: SMPTestFailure?) case chatItemTTL(chatItemTTL: Int64?) case networkConfig(networkConfig: NetCfg) case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) @@ -390,6 +394,7 @@ public enum ChatResponse: Decodable, Error { case .apiChats: return "apiChats" case .apiChat: return "apiChat" case .userSMPServers: return "userSMPServers" + case .sMPTestResult: return "smpTestResult" case .chatItemTTL: return "chatItemTTL" case .networkConfig: return "networkConfig" case .contactInfo: return "contactInfo" @@ -491,6 +496,7 @@ public enum ChatResponse: Decodable, Error { case let .apiChats(chats): return String(describing: chats) case let .apiChat(chat): return String(describing: chat) case let .userSMPServers(smpServers): return String(describing: smpServers) + case let .sMPTestResult(smpTestFailure): return String(describing: smpTestFailure) case let .chatItemTTL(chatItemTTL): return String(describing: chatItemTTL) case let .networkConfig(networkConfig): return String(describing: networkConfig) case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))" @@ -613,7 +619,7 @@ public struct ArchiveConfig: Encodable { } } -public struct DBEncryptionConfig: Encodable { +public struct DBEncryptionConfig: Codable { public init(currentKey: String, newKey: String) { self.currentKey = currentKey self.newKey = newKey @@ -623,6 +629,121 @@ public struct DBEncryptionConfig: Encodable { public var newKey: String } +public struct ServerCfg: Identifiable, Decodable { + public var server: String + public var preset: Bool + public var tested: Bool? + public var enabled: Bool +// public var sendEnabled: Bool // can we potentially want to prevent sending on the servers we use to receive? +// Even if we don't see the use case, it's probably better to allow it in the model +// In any case, "trusted/known" servers are out of scope of this change + + public init(server: String, preset: Bool, tested: Bool?, enabled: Bool) { + self.server = server + self.preset = preset + self.tested = tested + self.enabled = enabled + } + + public var id: String { server } + + public static var empty = ServerCfg(server: "", preset: false, tested: false, enabled: true) + + public struct SampleData { + public var preset: ServerCfg + public var custom: ServerCfg + public var untested: ServerCfg + } + + public static var sampleData = SampleData( + preset: ServerCfg( + server: "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", + preset: true, + tested: true, + enabled: true + ), + custom: ServerCfg( + server: "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", + preset: false, + tested: false, + enabled: false + ), + untested: ServerCfg( + server: "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion", + preset: false, + tested: nil, + enabled: true + ) + ) +} + +public enum SMPTestStep: String, Decodable { + case connect + case createQueue + case secureQueue + case deleteQueue + case disconnect + + var text: String { + switch self { + case .connect: return NSLocalizedString("Connect", comment: "server test step") + case .createQueue: return NSLocalizedString("Create queue", comment: "server test step") + case .secureQueue: return NSLocalizedString("Secure queue", comment: "server test step") + case .deleteQueue: return NSLocalizedString("Delete queue", comment: "server test step") + case .disconnect: return NSLocalizedString("Disconnect", comment: "server test step") + } + } +} + +public struct SMPTestFailure: Decodable, Error { + var testStep: SMPTestStep + var testError: AgentErrorType + + var localizedDescription: String { + let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@", comment: "server test failure"), testStep.text) + switch testError { + case .SMP(.AUTH): + return err + "," + NSLocalizedString("Server requires authentication to create queues, check password", comment: "server test error") + case .BROKER(.NETWORK): + return err + "," + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error") + default: + return err + } + } +} + +public struct ServerAddress { + public var hostnames: [String] + public var port: String + public var keyHash: String + public var basicAuth: String + + public init(hostnames: [String], port: String, keyHash: String, basicAuth: String = "") { + self.hostnames = hostnames + self.port = port + self.keyHash = keyHash + self.basicAuth = basicAuth + } + + public var uri: String { + "smp://\(keyHash)\(basicAuth == "" ? "" : ":" + basicAuth)@\(hostnames.joined(separator: ","))" + } + + static public var empty = ServerAddress( + hostnames: [], + port: "", + keyHash: "", + basicAuth: "" + ) + + static public var sampleData = ServerAddress( + hostnames: ["smp.simplex.im", "1234.onion"], + port: "", + keyHash: "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=", + basicAuth: "server_password" + ) +} + public struct NetCfg: Codable, Equatable { public var socksProxy: String? = nil public var hostMode: HostMode = .publicHost diff --git a/cabal.project b/cabal.project index 973338d69..a0114d703 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: f3b6ed4db024c946e6e5d119a07386d3746ec225 + tag: c2342cba057fa2333b5936a2254507b5b62e8de2 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index ba6dd1f7a..df4df503b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f3b6ed4db024c946e6e5d119a07386d3746ec225" = "0hsdrk80vy65dwf7gzmlkx48dy592qfm8d8sl7fpy9nr9y0rl8q1"; + "https://github.com/simplex-chat/simplexmq.git"."c2342cba057fa2333b5936a2254507b5b62e8de2" = "0fsi4lgq5x3dgy79g85s7isg3387ppwrqm4v8dndixlxn8cx3pyp"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index acf8ab9ed..e40abaf17 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -62,6 +62,7 @@ library Simplex.Chat.Migrations.M20221025_chat_settings Simplex.Chat.Migrations.M20221029_group_link_id Simplex.Chat.Migrations.M20221112_server_password + Simplex.Chat.Migrations.M20221115_server_cfg Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.ProfileGenerator diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 20a316a37..e42d8174c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -133,13 +133,14 @@ createChatDatabase filePrefix key yesToMigrations = do newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {smpServers, networkConfig, logConnections, logServerHosts} sendToast = do - let config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts} + servers <- resolveServers defaultServers + let servers' = servers {netCfg = networkConfig} + config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = servers'} sendNotification = fromMaybe (const $ pure ()) sendToast firstTime = dbNew chatStore activeTo <- newTVarIO ActiveNone currentUser <- newTVarIO user - servers <- resolveServers defaultServers - smpAgent <- getSMPAgentClient aCfg {database = AgentDB agentStore} servers {netCfg = networkConfig} + smpAgent <- getSMPAgentClient aCfg {database = AgentDB agentStore} servers' agentAsync <- newTVarIO Nothing idsDrg <- newTVarIO =<< drgNew inputQ <- newTBQueueIO tbqSize @@ -157,14 +158,21 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIsAsync, expireCIs} where resolveServers :: InitialAgentServers -> IO InitialAgentServers - resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of - Just smpServers' -> pure ss {smp = smpServers'} + resolveServers ss = case nonEmpty smpServers of + Just smpServers' -> pure ss {smp = L.map (\ServerCfg {server} -> server) smpServers'} _ -> case user of - Just usr -> do - userSmpServers <- withTransaction chatStore (`getSMPServers` usr) - pure ss {smp = fromMaybe defaultSMPServers $ nonEmpty userSmpServers} + Just user' -> do + userSmpServers <- withTransaction chatStore (`getSMPServers` user') + pure ss {smp = activeAgentServers cfg userSmpServers} _ -> pure ss +activeAgentServers :: ChatConfig -> [ServerCfg] -> NonEmpty SMPServerWithAuth +activeAgentServers ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} = + fromMaybe defaultSMPServers + . nonEmpty + . map (\ServerCfg {server} -> server) + . filter (\ServerCfg {enabled} -> enabled) + startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> Bool -> m (Async ()) startChatController user subConns enableExpireCIs = do asks smpAgent >>= resumeAgentClient @@ -669,13 +677,19 @@ processChatCommand = \case msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing pure CRNtfMessages {connEntity, msgTs = msgTs', ntfMessages} - GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore' (`getSMPServers` user)) - SetUserSMPServers smpServers -> withUser $ \user -> withChatLock "setUserSMPServers" $ do - withStore $ \db -> overwriteSMPServers db user smpServers + GetUserSMPServers -> do ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config - withAgent $ \a -> setSMPServers a (fromMaybe defaultSMPServers (nonEmpty smpServers)) + smpServers <- withUser (\user -> withStore' (`getSMPServers` user)) + let smpServers' = fromMaybe (L.map toServerCfg defaultSMPServers) $ nonEmpty smpServers + pure $ CRUserSMPServers smpServers' defaultSMPServers + where + toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True} + SetUserSMPServers (SMPServersConfig smpServers) -> withUser $ \user -> withChatLock "setUserSMPServers" $ do + withStore $ \db -> overwriteSMPServers db user smpServers + cfg <- asks config + withAgent $ \a -> setSMPServers a $ activeAgentServers cfg smpServers pure CRCmdOk - TestSMPServer smpServer -> CRSMPTestResult <$> withAgent (`testSMPServerConnection` smpServer) + TestSMPServer smpServer -> CRSmpTestResult <$> withAgent (`testSMPServerConnection` smpServer) APISetChatItemTTL newTTL_ -> withUser' $ \user -> checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do @@ -3201,12 +3215,14 @@ chatCommandP = "/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal), "/_leave #" *> (APILeaveGroup <$> A.decimal), "/_members #" *> (APIListMembers <$> A.decimal), - "/smp_servers default" $> SetUserSMPServers [], - "/smp_servers " *> (SetUserSMPServers <$> smpServersP), + -- /smp_servers is deprecated, use /smp and /_smp + "/smp_servers default" $> SetUserSMPServers (SMPServersConfig []), + "/smp_servers " *> (SetUserSMPServers . SMPServersConfig <$> smpServersP), "/smp_servers" $> GetUserSMPServers, - "/smp default" $> SetUserSMPServers [], + "/smp default" $> SetUserSMPServers (SMPServersConfig []), "/smp test " *> (TestSMPServer <$> strP), - "/smp " *> (SetUserSMPServers <$> smpServersP), + "/_smp " *> (SetUserSMPServers <$> jsonP), + "/smp " *> (SetUserSMPServers . SMPServersConfig <$> smpServersP), "/smp" $> GetUserSMPServers, "/_ttl " *> (APISetChatItemTTL <$> ciTTLDecimal), "/ttl " *> (APISetChatItemTTL <$> ciTTL), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9ca6e297d..5b1771b47 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -23,6 +23,7 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (ord) import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import Data.String import Data.Text (Text) @@ -186,7 +187,7 @@ data ChatCommand | APIDeleteGroupLink GroupId | APIGetGroupLink GroupId | GetUserSMPServers - | SetUserSMPServers [SMPServerWithAuth] + | SetUserSMPServers SMPServersConfig | TestSMPServer SMPServerWithAuth | APISetChatItemTTL (Maybe Int64) | APIGetChatItemTTL @@ -262,8 +263,8 @@ data ChatResponse | CRApiChat {chat :: AChat} | CRLastMessages {chatItems :: [AChatItem]} | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} - | CRUserSMPServers {smpServers :: [SMPServerWithAuth]} - | CRSMPTestResult {smpTestFailure :: Maybe SMPTestFailure} + | CRUserSMPServers {smpServers :: NonEmpty ServerCfg, presetSMPServers :: NonEmpty SMPServerWithAuth} + | CRSmpTestResult {smpTestFailure :: Maybe SMPTestFailure} | CRChatItemTTL {chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} | CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile} @@ -385,6 +386,9 @@ instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" +data SMPServersConfig = SMPServersConfig {smpServers :: [ServerCfg]} + deriving (Show, Generic, FromJSON) + data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} deriving (Show, Generic, FromJSON) @@ -467,6 +471,24 @@ data SwitchProgress = SwitchProgress instance ToJSON SwitchProgress where toEncoding = J.genericToEncoding J.defaultOptions +data ParsedServerAddress = ParsedServerAddress + { serverAddress :: Maybe ServerAddress, + parseError :: String + } + deriving (Show, Generic) + +instance ToJSON ParsedServerAddress where toEncoding = J.genericToEncoding J.defaultOptions + +data ServerAddress = ServerAddress + { hostnames :: NonEmpty String, + port :: String, + keyHash :: String, + basicAuth :: String + } + deriving (Show, Generic) + +instance ToJSON ServerAddress where toEncoding = J.genericToEncoding J.defaultOptions + data ChatError = ChatError {errorType :: ChatErrorType} | ChatErrorAgent {agentError :: AgentErrorType} diff --git a/src/Simplex/Chat/Migrations/M20221115_server_cfg.hs b/src/Simplex/Chat/Migrations/M20221115_server_cfg.hs new file mode 100644 index 000000000..409da91db --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20221115_server_cfg.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20221115_server_cfg where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20221115_server_cfg :: Query +m20221115_server_cfg = + [sql| +PRAGMA ignore_check_constraints=ON; + +ALTER TABLE smp_servers ADD COLUMN preset INTEGER DEFAULT 0 CHECK (preset NOT NULL); +ALTER TABLE smp_servers ADD COLUMN tested INTEGER; +ALTER TABLE smp_servers ADD COLUMN enabled INTEGER DEFAULT 1 CHECK (enabled NOT NULL); +UPDATE smp_servers SET preset = 0, enabled = 1; + +PRAGMA ignore_check_constraints=OFF; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 957573dff..035f51a36 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -385,6 +385,9 @@ CREATE TABLE smp_servers( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')), basic_auth TEXT, + preset INTEGER DEFAULT 0 CHECK(preset NOT NULL), + tested INTEGER, + enabled INTEGER DEFAULT 1 CHECK(enabled NOT NULL), UNIQUE(host, port) ); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index ebe92278d..a3c12ce5c 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -16,6 +16,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Functor (($>)) import Data.List (find) +import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB @@ -34,8 +35,10 @@ import Simplex.Chat.Types import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations), createAgentStore) import Simplex.Messaging.Agent.Store.SQLite (closeSQLiteStore) import Simplex.Messaging.Client (defaultNetworkConfig) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) -import Simplex.Messaging.Protocol (CorrId (..)) +import Simplex.Messaging.Protocol (BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..), SMPServerWithAuth) import Simplex.Messaging.Util (catchAll, safeDecodeUtf8) import System.Timeout (timeout) @@ -58,6 +61,8 @@ foreign export ccall "chat_recv_msg_wait" cChatRecvMsgWait :: StablePtr ChatCont foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO CJSONString +foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSONString + -- | check / migrate database and initialize chat controller on success cChatMigrateInit :: CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString cChatMigrateInit fp key ctrl = do @@ -107,6 +112,10 @@ cChatRecvMsgWait cc t = deRefStablePtr cc >>= (`chatRecvMsgWait` fromIntegral t) cChatParseMarkdown :: CString -> IO CJSONString cChatParseMarkdown s = newCAString . chatParseMarkdown =<< peekCAString s +-- | parse server address - returns ParsedServerAddress JSON +cChatParseServer :: CString -> IO CJSONString +cChatParseServer s = newCAString . chatParseServer =<< peekCAString s + mobileChatOpts :: ChatOpts mobileChatOpts = ChatOpts @@ -206,6 +215,18 @@ chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) chatParseMarkdown :: String -> JSONString chatParseMarkdown = LB.unpack . J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 . B.pack +chatParseServer :: String -> JSONString +chatParseServer = LB.unpack . J.encode . toServerAddress . strDecode . B.pack + where + toServerAddress :: Either String SMPServerWithAuth -> ParsedServerAddress + toServerAddress = \case + Right (ProtoServerWithAuth ProtocolServer {host, port, keyHash = C.KeyHash kh} auth) -> + let basicAuth = maybe "" (\(BasicAuth a) -> enc a) auth + in ParsedServerAddress (Just ServerAddress {hostnames = L.map enc host, port, keyHash = enc kh, basicAuth}) "" + Left e -> ParsedServerAddress Nothing e + enc :: StrEncoding a => a -> String + enc = B.unpack . strEncode + data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse} deriving (Generic) diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 6d618ef62..36119ebf6 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -16,7 +16,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import Options.Applicative import Simplex.Chat.Controller (updateStr, versionStr) -import Simplex.Messaging.Agent.Protocol (SMPServerWithAuth) +import Simplex.Chat.Types (ServerCfg (..)) import Simplex.Messaging.Client (NetworkConfig (..), defaultNetworkConfig) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) @@ -26,7 +26,7 @@ import System.FilePath (combine) data ChatOpts = ChatOpts { dbFilePrefix :: String, dbKey :: String, - smpServers :: [SMPServerWithAuth], + smpServers :: [ServerCfg], networkConfig :: NetworkConfig, logConnections :: Bool, logServerHosts :: Bool, @@ -155,7 +155,7 @@ fullNetworkConfig socksProxy tcpTimeout = let tcpConnectTimeout = (tcpTimeout * 3) `div` 2 in defaultNetworkConfig {socksProxy, tcpTimeout, tcpConnectTimeout} -parseSMPServers :: ReadM [SMPServerWithAuth] +parseSMPServers :: ReadM [ServerCfg] parseSMPServers = eitherReader $ parseAll smpServersP . B.pack parseSocksProxy :: ReadM (Maybe SocksProxy) @@ -167,8 +167,10 @@ parseServerPort = eitherReader $ parseAll serverPortP . B.pack serverPortP :: A.Parser (Maybe String) serverPortP = Just . B.unpack <$> A.takeWhile A.isDigit -smpServersP :: A.Parser [SMPServerWithAuth] -smpServersP = strP `A.sepBy1` A.char ';' +smpServersP :: A.Parser [ServerCfg] +smpServersP = (toServerCfg <$> strP) `A.sepBy1` A.char ';' + where + toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True} getChatOpts :: FilePath -> FilePath -> IO ChatOpts getChatOpts appDir defaultDbFileName = diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index da49845e2..d48fe2fd4 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -297,6 +297,7 @@ import Simplex.Chat.Migrations.M20221024_contact_used import Simplex.Chat.Migrations.M20221025_chat_settings import Simplex.Chat.Migrations.M20221029_group_link_id import Simplex.Chat.Migrations.M20221112_server_password +import Simplex.Chat.Migrations.M20221115_server_cfg import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (ACorrId, AgentMsgId, ConnId, InvitationId, MsgMeta (..)) @@ -304,7 +305,7 @@ import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) -import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), SMPServerWithAuth, pattern SMPServer) +import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), pattern SMPServer) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) import UnliftIO.STM @@ -344,7 +345,8 @@ schemaMigrations = ("20221024_contact_used", m20221024_contact_used), ("20221025_chat_settings", m20221025_chat_settings), ("20221029_group_link_id", m20221029_group_link_id), - ("20221112_server_password", m20221112_server_password) + ("20221112_server_password", m20221112_server_password), + ("20221115_server_cfg", m20221115_server_cfg) ] -- | The list of migrations in ascending order by date @@ -4257,35 +4259,38 @@ toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) toGroupChatItemList _ _ _ _ = [] -getSMPServers :: DB.Connection -> User -> IO [SMPServerWithAuth] +getSMPServers :: DB.Connection -> User -> IO [ServerCfg] getSMPServers db User {userId} = - map toSmpServer + map toServerCfg <$> DB.query db [sql| - SELECT host, port, key_hash, basic_auth + SELECT host, port, key_hash, basic_auth, preset, tested, enabled FROM smp_servers WHERE user_id = ?; |] (Only userId) where - toSmpServer :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text) -> SMPServerWithAuth - toSmpServer (host, port, keyHash, auth_) = ProtoServerWithAuth (SMPServer host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) + toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg + toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) = + let server = ProtoServerWithAuth (SMPServer host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) + in ServerCfg {server, preset, tested, enabled} -overwriteSMPServers :: DB.Connection -> User -> [SMPServerWithAuth] -> ExceptT StoreError IO () -overwriteSMPServers db User {userId} smpServers = +overwriteSMPServers :: DB.Connection -> User -> [ServerCfg] -> ExceptT StoreError IO () +overwriteSMPServers db User {userId} servers = checkConstraint SEUniqueID . ExceptT $ do currentTs <- getCurrentTime DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId) - forM_ smpServers $ \(ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) -> + forM_ servers $ \ServerCfg {server, preset, tested, enabled} -> do + let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server DB.execute db [sql| INSERT INTO smp_servers - (host, port, key_hash, basic_auth, user_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?) + (host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) |] - (host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_, userId, currentTs, currentTs) + (host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_, preset, tested, enabled, userId, currentTs, currentTs) pure $ Right () createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index e2c00835c..437cb9cb5 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -44,6 +44,7 @@ import GHC.Generics (Generic) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Protocol (SMPServerWithAuth) import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) class IsContact a where @@ -1449,3 +1450,18 @@ encodeJSON = safeDecodeUtf8 . LB.toStrict . J.encode decodeJSON :: FromJSON a => Text -> Maybe a decodeJSON = J.decode . LB.fromStrict . encodeUtf8 + +data ServerCfg = ServerCfg + { server :: SMPServerWithAuth, + preset :: Bool, + tested :: Maybe Bool, + enabled :: Bool + } + deriving (Show, Generic) + +instance ToJSON ServerCfg where + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + +instance FromJSON ServerCfg where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index eb2ad3a12..d3f6e6bbe 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -18,6 +18,7 @@ import Data.Char (toUpper) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) +import qualified Data.List.NonEmpty as L import Data.Maybe (isJust, isNothing, mapMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -65,8 +66,8 @@ responseToView user_ testView ts = \case CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats] CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat] CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] - CRUserSMPServers smpServers -> viewSMPServers smpServers testView - CRSMPTestResult testFailure -> viewSMPTestResult testFailure + CRUserSMPServers smpServers _ -> viewSMPServers (L.toList smpServers) testView + CRSmpTestResult testFailure -> viewSMPTestResult testFailure CRChatItemTTL ttl -> viewChatItemTTL ttl CRNetworkConfig cfg -> viewNetworkConfig cfg CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile @@ -622,7 +623,7 @@ viewUserProfile Profile {displayName, fullName} = "(the updated profile will be sent to all your contacts)" ] -viewSMPServers :: [SMPServerWithAuth] -> Bool -> [StyledString] +viewSMPServers :: [ServerCfg] -> Bool -> [StyledString] viewSMPServers smpServers testView = if testView then [customSMPServers] @@ -690,8 +691,8 @@ viewConnectionStats ConnectionStats {rcvServers, sndServers} = ["receiving messages via: " <> viewServerHosts rcvServers | not $ null rcvServers] <> ["sending messages via: " <> viewServerHosts sndServers | not $ null sndServers] -viewServers :: [SMPServerWithAuth] -> StyledString -viewServers = plain . intercalate ", " . map (B.unpack . strEncode) +viewServers :: [ServerCfg] -> StyledString +viewServers = plain . intercalate ", " . map (B.unpack . strEncode . (\ServerCfg {server} -> server)) viewServerHosts :: [SMPServer] -> StyledString viewServerHosts = plain . intercalate ", " . map showSMPServer diff --git a/stack.yaml b/stack.yaml index 7f8d1cad6..0881e42ee 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: f3b6ed4db024c946e6e5d119a07386d3746ec225 + commit: c2342cba057fa2333b5936a2254507b5b62e8de2 # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: 34309410eb2069b029b8fc1872deb1e0db123294 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 11ab2771d..57b80a109 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -25,7 +25,7 @@ import Simplex.Chat.Options import Simplex.Chat.Store import Simplex.Chat.Terminal import Simplex.Chat.Terminal.Output (newChatTerminal) -import Simplex.Chat.Types (Profile, User (..)) +import Simplex.Chat.Types (Profile, ServerCfg (..), User (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig) @@ -51,7 +51,7 @@ testOpts = { dbFilePrefix = undefined, dbKey = "", -- dbKey = "this is a pass-phrase to encrypt the database", - smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001"], + smpServers = [ServerCfg "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001" False Nothing True], networkConfig = defaultNetworkConfig, logConnections = False, logServerHosts = False, diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 1391e103f..e26a3082f 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -2867,15 +2867,15 @@ testGetSetSMPServers :: IO () testGetSetSMPServers = testChat2 aliceProfile bobProfile $ \alice _ -> do - alice #$> ("/smp_servers", id, "no custom SMP servers saved") - alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok") - alice #$> ("/smp_servers", id, "smp://1234-w==@smp1.example.im") - alice #$> ("/smp_servers smp://1234-w==:password@smp1.example.im", id, "ok") - alice #$> ("/smp_servers", id, "smp://1234-w==:password@smp1.example.im") - alice #$> ("/smp_servers smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok") - alice #$> ("/smp_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") - alice #$> ("/smp_servers default", id, "ok") - alice #$> ("/smp_servers", id, "no custom SMP servers saved") + alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001") + alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok") + alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im") + alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok") + alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im") + alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok") + alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") + alice #$> ("/smp default", id, "ok") + alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001") testTestSMPServerConnection :: IO () testTestSMPServerConnection =