diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 8d398eb89..13fe0737e 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -83,7 +83,7 @@ final class ChatModel: ObservableObject { // current WebRTC call @Published var callInvitations: Dictionary = [:] @Published var activeCall: Call? - @Published var callCommand: WCallCommand? + let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor() @Published var showCallView = false // remote desktop @Published var remoteCtrlSession: RemoteCtrlSession? diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e010de3e8..19030a284 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -605,27 +605,29 @@ func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { throw r } -func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? { - let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq) +func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? { + let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq) if let alert = alert { AlertManager.shared.showAlert(alert) return nil } else { - return connReqType + return r } } -func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) { +func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, PendingContactConnection)?, Alert?) { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiConnect: no current user") return (nil, nil) } let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq)) + let m = ChatModel.shared switch r { - case .sentConfirmation: return (.invitation, nil) - case .sentInvitation: return (.contact, nil) + case let .sentConfirmation(_, connection): + return ((.invitation, connection), nil) + case let .sentInvitation(_, connection): + return ((.contact, connection), nil) case let .contactAlreadyExists(_, contact): - let m = ChatModel.shared if let c = m.getContactChat(contact.contactId) { await MainActor.run { m.chatId = c.id } } @@ -1362,18 +1364,6 @@ func processReceivedMsg(_ res: ChatResponse) async { let m = ChatModel.shared logger.debug("processReceivedMsg: \(res.responseType)") switch res { - case let .newContactConnection(user, connection): - if active(user) { - await MainActor.run { - m.updateContactConnection(connection) - } - } - case let .contactConnectionDeleted(user, connection): - if active(user) { - await MainActor.run { - m.removeChat(connection.id) - } - } case let .contactDeletedByContact(user, contact): if active(user) && contact.directOrUsed { await MainActor.run { @@ -1666,36 +1656,40 @@ func processReceivedMsg(_ res: ChatResponse) async { activateCall(invitation) case let .callOffer(_, contact, callType, offer, sharedKey, _): await withCall(contact) { call in - call.callState = .offerReceived - call.peerMedia = callType.media - call.sharedKey = sharedKey + await MainActor.run { + call.callState = .offerReceived + call.peerMedia = callType.media + call.sharedKey = sharedKey + } let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) let iceServers = getIceServers() logger.debug(".callOffer useRelay \(useRelay)") logger.debug(".callOffer iceServers \(String(describing: iceServers))") - m.callCommand = .offer( + await m.callCommand.processCommand(.offer( offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey, iceServers: iceServers, relay: useRelay - ) + )) } case let .callAnswer(_, contact, answer): await withCall(contact) { call in - call.callState = .answerReceived - m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates) + await MainActor.run { + call.callState = .answerReceived + } + await m.callCommand.processCommand(.answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)) } case let .callExtraInfo(_, contact, extraInfo): await withCall(contact) { _ in - m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates) + await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates)) } case let .callEnded(_, contact): if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) { CallController.shared.reportCallRemoteEnded(invitation: invitation) } await withCall(contact) { call in - m.callCommand = .end + await m.callCommand.processCommand(.end) CallController.shared.reportCallRemoteEnded(call: call) } case .chatSuspended: @@ -1753,9 +1747,9 @@ func processReceivedMsg(_ res: ChatResponse) async { logger.debug("unsupported event: \(res.responseType)") } - func withCall(_ contact: Contact, _ perform: (Call) -> Void) async { + func withCall(_ contact: Contact, _ perform: (Call) async -> Void) async { if let call = m.activeCall, call.contact.apiId == contact.apiId { - await MainActor.run { perform(call) } + await perform(call) } else { logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") } diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index ad9d90c38..e613476a1 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -49,10 +49,10 @@ struct ActiveCallView: View { } .onDisappear { logger.debug("ActiveCallView: disappear") + Task { await m.callCommand.setClient(nil) } AppDelegate.keepScreenOn(false) client?.endCall() } - .onChange(of: m.callCommand) { _ in sendCommandToClient()} .background(.black) .preferredColorScheme(.dark) } @@ -60,19 +60,8 @@ struct ActiveCallView: View { private func createWebRTCClient() { if client == nil && canConnectCall { client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) - sendCommandToClient() - } - } - - private func sendCommandToClient() { - if call == m.activeCall, - m.activeCall != nil, - let client = client, - let cmd = m.callCommand { - m.callCommand = nil - logger.debug("sendCallCommand: \(cmd.cmdType)") Task { - await client.sendCallCommand(command: cmd) + await m.callCommand.setClient(client) } } } @@ -168,8 +157,10 @@ struct ActiveCallView: View { } case let .error(message): logger.debug("ActiveCallView: command error: \(message)") + AlertManager.shared.showAlert(Alert(title: Text("Error"), message: Text(message))) case let .invalid(type): logger.debug("ActiveCallView: invalid response: \(type)") + AlertManager.shared.showAlert(Alert(title: Text("Invalid response"), message: Text(type))) } } } @@ -255,7 +246,6 @@ struct ActiveCallOverlay: View { HStack { Text(call.encryptionStatus) if let connInfo = call.connectionInfo { -// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))") Text("(") + Text(connInfo.text) + Text(")") } } diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift index 6e3066d1a..194af3ab0 100644 --- a/apps/ios/Shared/Views/Call/CallManager.swift +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -22,7 +22,7 @@ class CallManager { let m = ChatModel.shared if let call = m.activeCall, call.callkitUUID == callUUID { m.showCallView = true - m.callCommand = .capabilities(media: call.localMedia) + Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) } return true } return false @@ -57,19 +57,21 @@ class CallManager { m.activeCall = call m.showCallView = true - m.callCommand = .start( + Task { + await m.callCommand.processCommand(.start( media: invitation.callType.media, aesKey: invitation.sharedKey, iceServers: iceServers, relay: useRelay - ) + )) + } } } func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool { if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID { let m = ChatModel.shared - m.callCommand = .media(media: media, enable: enable) + Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) } return true } return false @@ -94,11 +96,13 @@ class CallManager { completed() } else { logger.debug("CallManager.endCall: ending call...") - m.callCommand = .end - m.activeCall = nil - m.showCallView = false - completed() Task { + await m.callCommand.processCommand(.end) + await MainActor.run { + m.activeCall = nil + m.showCallView = false + completed() + } do { try await apiEndCall(call.contact) } catch { diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index ceeaf513d..c21ef5019 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -335,6 +335,50 @@ extension WCallResponse: Encodable { } } +actor WebRTCCommandProcessor { + private var client: WebRTCClient? = nil + private var commands: [WCallCommand] = [] + private var running: Bool = false + + func setClient(_ client: WebRTCClient?) async { + logger.debug("WebRTC: setClient, commands count \(self.commands.count)") + self.client = client + if client != nil { + await processAllCommands() + } else { + commands.removeAll() + } + } + + func processCommand(_ c: WCallCommand) async { +// logger.debug("WebRTC: process command \(c.cmdType)") + commands.append(c) + if !running && client != nil { + await processAllCommands() + } + } + + func processAllCommands() async { + logger.debug("WebRTC: process all commands, commands count \(self.commands.count), client == nil \(self.client == nil)") + if let client = client { + running = true + while let c = commands.first, shouldRunCommand(client, c) { + commands.remove(at: 0) + await client.sendCallCommand(command: c) + logger.debug("WebRTC: processed cmd \(c.cmdType)") + } + running = false + } + } + + func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool { + switch c { + case .capabilities, .start, .offer, .end: true + default: client.activeCall.wrappedValue != nil + } + } +} + struct ConnectionState: Codable, Equatable { var connectionState: String var iceConnectionState: String @@ -358,26 +402,12 @@ struct ConnectionInfo: Codable, Equatable { return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)" } } - - var protocolText: String { - let unknown = NSLocalizedString("unknown", comment: "connection info") - let local = localCandidate?.protocol?.uppercased() ?? unknown - let localRelay = localCandidate?.relayProtocol?.uppercased() ?? unknown - let remote = remoteCandidate?.protocol?.uppercased() ?? unknown - let localText = localRelay == local || localCandidate?.relayProtocol == nil - ? local - : "\(local) (\(localRelay))" - return local == remote - ? localText - : "\(localText) / \(remote)" - } } // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate struct RTCIceCandidate: Codable, Equatable { var candidateType: RTCIceCandidateType? var `protocol`: String? - var relayProtocol: String? var sdpMid: String? var sdpMLineIndex: Int? var candidate: String diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index 5ecec1a98..acb459938 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -21,7 +21,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg struct Call { var connection: RTCPeerConnection - var iceCandidates: [RTCIceCandidate] + var iceCandidates: IceCandidates var localMedia: CallMediaType var localCamera: RTCVideoCapturer? var localVideoSource: RTCVideoSource? @@ -33,10 +33,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg var frameDecryptor: RTCFrameDecryptor? } + actor IceCandidates { + private var candidates: [RTCIceCandidate] = [] + + func getAndClear() async -> [RTCIceCandidate] { + let cs = candidates + candidates = [] + return cs + } + + func append(_ c: RTCIceCandidate) async { + candidates.append(c) + } + } + private let rtcAudioSession = RTCAudioSession.sharedInstance() private let audioQueue = DispatchQueue(label: "audio") private var sendCallResponse: (WVAPIMessage) async -> Void - private var activeCall: Binding + var activeCall: Binding private var localRendererAspectRatio: Binding @available(*, unavailable) @@ -60,7 +74,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"), ] - func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ remoteIceCandidates: [RTCIceCandidate], _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { + func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay) connection.delegate = self createAudioSender(connection) @@ -87,7 +101,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } return Call( connection: connection, - iceCandidates: remoteIceCandidates, + iceCandidates: IceCandidates(), localMedia: mediaType, localCamera: localCamera, localVideoSource: localVideoSource, @@ -144,26 +158,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg logger.debug("starting incoming call - create webrtc session") if activeCall.wrappedValue != nil { endCall() } let encryption = WebRTCClient.enableEncryption - let call = initializeCall(iceServers?.toWebRTCIceServers(), [], media, encryption ? aesKey : nil, relay) + let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay) activeCall.wrappedValue = call - call.connection.offer { answer in - Task { - let gotCandidates = await self.waitWithTimeout(10_000, stepMs: 1000, until: { self.activeCall.wrappedValue?.iceCandidates.count ?? 0 > 0 }) - if gotCandidates { - await self.sendCallResponse(.init( - corrId: nil, - resp: .offer( - offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))), - iceCandidates: compressToBase64(input: encodeJSON(self.activeCall.wrappedValue?.iceCandidates ?? [])), - capabilities: CallCapabilities(encryption: encryption) - ), - command: command) - ) - } else { - self.endCall() - } - } - + let (offer, error) = await call.connection.offer() + if let offer = offer { + resp = .offer( + offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))), + iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())), + capabilities: CallCapabilities(encryption: encryption) + ) + self.waitForMoreIceCandidates() + } else { + resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")") } case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay): if activeCall.wrappedValue != nil { @@ -172,26 +178,21 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg resp = .error(message: "accept: encryption is not supported") } else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)), let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) { - let call = initializeCall(iceServers?.toWebRTCIceServers(), remoteIceCandidates, media, WebRTCClient.enableEncryption ? aesKey : nil, relay) + let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay) activeCall.wrappedValue = call let pc = call.connection if let type = offer.type, let sdp = offer.sdp { if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil { - pc.answer { answer in + let (answer, error) = await pc.answer() + if let answer = answer { self.addIceCandidates(pc, remoteIceCandidates) -// Task { -// try? await Task.sleep(nanoseconds: 32_000 * 1000000) - Task { - await self.sendCallResponse(.init( - corrId: nil, - resp: .answer( - answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))), - iceCandidates: compressToBase64(input: encodeJSON(call.iceCandidates)) - ), - command: command) - ) - } -// } + resp = .answer( + answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))), + iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())) + ) + self.waitForMoreIceCandidates() + } else { + resp = .error(message: "answer error: \(error?.localizedDescription ?? "unknown error")") } } else { resp = .error(message: "accept: remote description is not set") @@ -234,6 +235,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg resp = .ok } case .end: + // TODO possibly, endCall should be called before returning .ok await sendCallResponse(.init(corrId: nil, resp: .ok, command: command)) endCall() } @@ -242,6 +244,33 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + func getInitialIceCandidates() async -> [RTCIceCandidate] { + await untilIceComplete(timeoutMs: 750, stepMs: 150) {} + let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? [] + logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)") + return candidates + } + + func waitForMoreIceCandidates() { + Task { + await untilIceComplete(timeoutMs: 12000, stepMs: 1500) { + let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? [] + if candidates.count > 0 { + logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)") + await self.sendIceCandidates(candidates) + } + } + } + } + + func sendIceCandidates(_ candidates: [RTCIceCandidate]) async { + await self.sendCallResponse(.init( + corrId: nil, + resp: .ice(iceCandidates: compressToBase64(input: encodeJSON(candidates))), + command: nil) + ) + } + func enableMedia(_ media: CallMediaType, _ enable: Bool) { logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)") media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable) @@ -387,12 +416,13 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg audioSessionToDefaults() } - func waitWithTimeout(_ timeoutMs: UInt64, stepMs: UInt64, until success: () -> Bool) async -> Bool { - let startedAt = DispatchTime.now() - while !success() && startedAt.uptimeNanoseconds + timeoutMs * 1000000 > DispatchTime.now().uptimeNanoseconds { - guard let _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) else { break } - } - return success() + func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async { + var t: UInt64 = 0 + repeat { + _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) + t += stepMs + await action() + } while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete } } @@ -405,25 +435,33 @@ extension WebRTC.RTCPeerConnection { optionalConstraints: nil) } - func offer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { - offer(for: mediaConstraints()) { (sdp, error) in - guard let sdp = sdp else { - return + func offer() async -> (RTCSessionDescription?, Error?) { + await withCheckedContinuation { cont in + offer(for: mediaConstraints()) { (sdp, error) in + self.processSDP(cont, sdp, error) } - self.setLocalDescription(sdp, completionHandler: { (error) in - completion(sdp) - }) } } - func answer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { - answer(for: mediaConstraints()) { (sdp, error) in - guard let sdp = sdp else { - return + func answer() async -> (RTCSessionDescription?, Error?) { + await withCheckedContinuation { cont in + answer(for: mediaConstraints()) { (sdp, error) in + self.processSDP(cont, sdp, error) } + } + } + + private func processSDP(_ cont: CheckedContinuation<(RTCSessionDescription?, Error?), Never>, _ sdp: RTCSessionDescription?, _ error: Error?) { + if let sdp = sdp { self.setLocalDescription(sdp, completionHandler: { (error) in - completion(sdp) + if let error = error { + cont.resume(returning: (nil, error)) + } else { + cont.resume(returning: (sdp, nil)) + } }) + } else { + cont.resume(returning: (nil, error)) } } } @@ -479,6 +517,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate { default: enableSpeaker = false } setSpeakerEnabledAndConfigureSession(enableSpeaker) + case .connected: sendConnectedEvent(connection) case .disconnected, .failed: endCall() default: do {} } @@ -491,7 +530,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate { func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) { // logger.debug("Connection generated candidate \(candidate.debugDescription)") - activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil, nil)) + Task { + await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil)) + } } func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) { @@ -506,10 +547,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate { lastReceivedMs lastDataReceivedMs: Int32, changeReason reason: String) { // logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)") - sendConnectedEvent(connection, local: local, remote: remote) } - func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection, local: WebRTC.RTCIceCandidate, remote: WebRTC.RTCIceCandidate) { + func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection) { connection.statistics { (stats: RTCStatisticsReport) in stats.statistics.values.forEach { stat in // logger.debug("Stat \(stat.debugDescription)") @@ -517,24 +557,25 @@ extension WebRTCClient: RTCPeerConnectionDelegate { let localId = stat.values["localCandidateId"] as? String, let remoteId = stat.values["remoteCandidateId"] as? String, let localStats = stats.statistics[localId], - let remoteStats = stats.statistics[remoteId], - local.sdp.contains("\((localStats.values["ip"] as? String ?? "--")) \((localStats.values["port"] as? String ?? "--"))") && - remote.sdp.contains("\((remoteStats.values["ip"] as? String ?? "--")) \((remoteStats.values["port"] as? String ?? "--"))") + let remoteStats = stats.statistics[remoteId] { Task { await self.sendCallResponse(.init( corrId: nil, resp: .connected(connectionInfo: ConnectionInfo( - localCandidate: local.toCandidate( - RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String), - localStats.values["protocol"] as? String, - localStats.values["relayProtocol"] as? String + localCandidate: RTCIceCandidate( + candidateType: RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String), + protocol: localStats.values["protocol"] as? String, + sdpMid: nil, + sdpMLineIndex: nil, + candidate: "" ), - remoteCandidate: remote.toCandidate( - RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String), - remoteStats.values["protocol"] as? String, - remoteStats.values["relayProtocol"] as? String - ))), + remoteCandidate: RTCIceCandidate( + candidateType: RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String), + protocol: remoteStats.values["protocol"] as? String, + sdpMid: nil, + sdpMLineIndex: nil, + candidate: ""))), command: nil) ) } @@ -634,11 +675,10 @@ extension RTCIceCandidate { } extension WebRTC.RTCIceCandidate { - func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?, _ relayProtocol: String?) -> RTCIceCandidate { + func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?) -> RTCIceCandidate { RTCIceCandidate( candidateType: candidateType, protocol: `protocol`, - relayProtocol: relayProtocol, sdpMid: sdpMid, sdpMLineIndex: Int(sdpMLineIndex), candidate: sdp diff --git a/apps/ios/Shared/Views/NewChat/CreateLinkView.swift b/apps/ios/Shared/Views/NewChat/CreateLinkView.swift index 0b9cfe7a1..3be9e1c3b 100644 --- a/apps/ios/Shared/Views/NewChat/CreateLinkView.swift +++ b/apps/ios/Shared/Views/NewChat/CreateLinkView.swift @@ -73,6 +73,7 @@ struct CreateLinkView: View { Task { if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { await MainActor.run { + m.updateContactConnection(pcc) connReqInvitation = connReq contactConnection = pcc m.connReqInv = connReq diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 637c01032..170805b48 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -52,6 +52,9 @@ struct NewChatButton: View { func addContactAction() { Task { if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { + await MainActor.run { + ChatModel.shared.updateContactConnection(pcc) + } actionSheet = .createLink(link: connReq, connection: pcc) } } @@ -346,7 +349,10 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) { Task { - if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { + if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) { + await MainActor.run { + ChatModel.shared.updateContactConnection(pcc) + } let crt: ConnReqType if let plan = connectionPlan { crt = planToConnReqType(plan) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 9128f67f2..3d2c21392 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -505,8 +505,8 @@ public enum ChatResponse: Decodable, Error { case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) - case sentConfirmation(user: UserRef) - case sentInvitation(user: UserRef) + case sentConfirmation(user: UserRef, connection: PendingContactConnection) + case sentInvitation(user: UserRef, connection: PendingContactConnection) case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case contactAlreadyExists(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) @@ -605,7 +605,6 @@ public enum ChatResponse: Decodable, Error { case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) - case newContactConnection(user: UserRef, connection: PendingContactConnection) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) // remote desktop responses/events case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) @@ -752,7 +751,6 @@ public enum ChatResponse: Decodable, Error { case .ntfTokenStatus: return "ntfTokenStatus" case .ntfToken: return "ntfToken" case .ntfMessages: return "ntfMessages" - case .newContactConnection: return "newContactConnection" case .contactConnectionDeleted: return "contactConnectionDeleted" case .remoteCtrlList: return "remoteCtrlList" case .remoteCtrlFound: return "remoteCtrlFound" @@ -803,11 +801,11 @@ public enum ChatResponse: Decodable, Error { case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") - case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation) + case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)") case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) - case .sentConfirmation: return noDetails - case .sentInvitation: return noDetails + case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) + case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact)) @@ -900,7 +898,6 @@ public enum ChatResponse: Decodable, Error { case let .ntfTokenStatus(status): return String(describing: status) case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") - case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 51c362325..5f30d21bb 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -370,7 +370,6 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) { InfoText(call.callState.text) val connInfo = call.connectionInfo - // val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})" val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" InfoText(call.encryptionStatus + connInfoText) } @@ -585,8 +584,8 @@ fun PreviewActiveCallOverlayVideo() { localMedia = CallMediaType.Video, peerMedia = CallMediaType.Video, connectionInfo = ConnectionInfo( - RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null), - RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null) + RTCIceCandidate(RTCIceCandidateType.Host, "tcp"), + RTCIceCandidate(RTCIceCandidateType.Host, "tcp") ) ), speakerCanBeEnabled = true, @@ -611,8 +610,8 @@ fun PreviewActiveCallOverlayAudio() { localMedia = CallMediaType.Audio, peerMedia = CallMediaType.Audio, connectionInfo = ConnectionInfo( - RTCIceCandidate(RTCIceCandidateType.Host, "udp", null), - RTCIceCandidate(RTCIceCandidateType.Host, "udp", null) + RTCIceCandidate(RTCIceCandidateType.Host, "udp"), + RTCIceCandidate(RTCIceCandidateType.Host, "udp") ) ), speakerCanBeEnabled = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 83ae90cb2..34b6fd99f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -173,6 +173,8 @@ class AppPreferences { val connectRemoteViaMulticastAuto = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO, true) val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true) + val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null) + private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( get = fun() = settings.getInt(prefName, default), @@ -317,6 +319,7 @@ class AppPreferences { private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast" private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" + private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState" } } @@ -891,20 +894,21 @@ object ChatController { return null } - suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): Boolean { + suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): PendingContactConnection? { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiConnect: no current user") - return false + return null } val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq)) when { - r is CR.SentConfirmation || r is CR.SentInvitation -> return true + r is CR.SentConfirmation -> return r.connection + r is CR.SentInvitation -> return r.connection r is CR.ContactAlreadyExists -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.contact.displayName) ) - return false + return null } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.InvalidConnReq -> { @@ -912,7 +916,7 @@ object ChatController { generalGetString(MR.strings.invalid_connection_link), generalGetString(MR.strings.please_check_correct_link_and_maybe_ask_for_a_new_one) ) - return false + return null } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.SMP @@ -921,13 +925,13 @@ object ChatController { generalGetString(MR.strings.connection_error_auth), generalGetString(MR.strings.connection_error_auth_desc) ) - return false + return null } else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiConnect", generalGetString(MR.strings.connection_error), r) } - return false + return null } } } @@ -1526,16 +1530,6 @@ object ChatController { fun active(user: UserLike): Boolean = activeUser(rhId, user) chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) when (r) { - is CR.NewContactConnection -> { - if (active(r.user)) { - chatModel.updateContactConnection(rhId, r.connection) - } - } - is CR.ContactConnectionDeleted -> { - if (active(r.user)) { - chatModel.removeChat(rhId, r.connection.id) - } - } is CR.ContactDeletedByContact -> { if (active(r.user) && r.contact.directOrUsed) { chatModel.updateContact(rhId, r.contact) @@ -3707,8 +3701,8 @@ sealed class CR { @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() - @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR() - @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR() + @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR() + @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR() @@ -3802,7 +3796,6 @@ sealed class CR { @Serializable @SerialName("callAnswer") class CallAnswer(val user: UserRef, val contact: Contact, val answer: WebRTCSession): CR() @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: UserRef, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR() @Serializable @SerialName("callEnded") class CallEnded(val user: UserRef, val contact: Contact): CR() - @Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR() // remote events (desktop) @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() @@ -3951,7 +3944,6 @@ sealed class CR { is CallAnswer -> "callAnswer" is CallExtraInfo -> "callExtraInfo" is CallEnded -> "callEnded" - is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" is RemoteHostList -> "remoteHostList" is CurrentRemoteHost -> "currentRemoteHost" @@ -4006,11 +3998,11 @@ sealed class CR { is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") - is Invitation -> withUser(user, connReqInvitation) + is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection") is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) - is SentConfirmation -> withUser(user, noDetails()) - is SentInvitation -> withUser(user, noDetails()) + is SentConfirmation -> withUser(user, json.encodeToString(connection)) + is SentInvitation -> withUser(user, json.encodeToString(connection)) is SentInvitationToContact -> withUser(user, json.encodeToString(contact)) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact)) @@ -4098,7 +4090,6 @@ sealed class CR { is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}") is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}") is CallEnded -> withUser(user, "contact: ${contact.id}") - is NewContactConnection -> withUser(user, json.encodeToString(connection)) is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) // remote events (mobile) is RemoteHostList -> json.encodeToString(remoteHosts) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 6a357d26f..3e79dfb4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -127,18 +127,10 @@ sealed class WCallResponse { "${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}" } } - - val protocolText: String get() { - val local = localCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown" - val localRelay = localCandidate?.relayProtocol?.uppercase(Locale.ROOT) ?: "unknown" - val remote = remoteCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown" - val localText = if (localRelay == local || localCandidate?.relayProtocol == null) local else "$local ($localRelay)" - return if (local == remote) localText else "$localText / $remote" - } } // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate -@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?, val relayProtocol: String?) +@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?) // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer @Serializable data class RTCIceServer(val urls: List, val username: String? = null, val credential: String? = null) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt index a16275271..6f3caf467 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt @@ -108,6 +108,7 @@ private fun createInvitation( withApi { val r = m.controller.apiAddContact(rhId, incognito = m.controller.appPrefs.incognito.get()) if (r != null) { + m.updateContactConnection(rhId, r.second) connReqInvitation.value = r.first contactConnection.value = r.second } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index 4deeda3e2..2439b16c3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -283,10 +283,11 @@ suspend fun connectViaUri( incognito: Boolean, connectionPlan: ConnectionPlan?, close: (() -> Unit)? -): Boolean { - val r = chatModel.controller.apiConnect(rhId, incognito, uri.toString()) +) { + val pcc = chatModel.controller.apiConnect(rhId, incognito, uri.toString()) val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION - if (r) { + if (pcc != null) { + chatModel.updateContactConnection(rhId, pcc) close?.invoke() AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), @@ -299,7 +300,6 @@ suspend fun connectViaUri( hostDevice = hostDevice(rhId), ) } - return r } fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 33453f5e8..d6cc9c7bb 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.ChatController import chat.simplex.common.model.ChatModel -import chat.simplex.common.platform.desktopPlatform import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.TerminalView @@ -31,10 +30,30 @@ import java.io.File val simplexWindowState = SimplexWindowState() fun showApp() = application { - // For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366, - // it will show 1356. But after that we can still update it to 1366 by changing window state. Just making it +10 now here - val width = if (desktopPlatform.isLinux()) 1376.dp else 1366.dp - val windowState = rememberWindowState(placement = WindowPlacement.Floating, width = width, height = 768.dp) + // Creates file if not exists; comes with proper defaults + val state = getStoredWindowState() + + val windowState: WindowState = rememberWindowState( + placement = WindowPlacement.Floating, + width = state.width.dp, + height = state.height.dp, + position = WindowPosition(state.x.dp, state.y.dp) + ) + + LaunchedEffect( + windowState.position.x.value, + windowState.position.y.value, + windowState.size.width.value, + windowState.size.height.value + ) { + storeWindowState(WindowPositionSize( + x = windowState.position.x.value.toInt(), + y = windowState.position.y.value.toInt(), + width = windowState.size.width.value.toInt(), + height = windowState.size.height.value.toInt() + )) + } + simplexWindowState.windowState = windowState // Reload all strings in all @Composable's after language change at runtime if (remember { ChatController.appPrefs.appLanguage.state }.value != "") { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt new file mode 100644 index 000000000..2a1a26df9 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt @@ -0,0 +1,36 @@ +package chat.simplex.common + +import chat.simplex.common.model.json +import chat.simplex.common.platform.appPreferences +import chat.simplex.common.platform.desktopPlatform +import kotlinx.serialization.* + +@Serializable +data class WindowPositionSize( + val width: Int = 1366, + val height: Int = 768, + val x: Int = 0, + val y: Int = 0, +) + +fun getStoredWindowState(): WindowPositionSize = + try { + val str = appPreferences.desktopWindowState.get() + var state = if (str == null) { + WindowPositionSize() + } else { + json.decodeFromString(str) + } + + // For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366, + // it will show 1356. But after that we can still update it to 1366 by changing window state. Just making it +10 now here + if (desktopPlatform.isLinux() && state.width == 1366) { + state = state.copy(width = 1376) + } + state + } catch (e: Throwable) { + WindowPositionSize() + } + +fun storeWindowState(state: WindowPositionSize) = + appPreferences.desktopWindowState.set(json.encodeToString(state)) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index c2665109f..22d39409e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -136,7 +136,6 @@ private fun SendStateUpdates() { .collect { call -> val state = call.callState.text val connInfo = call.connectionInfo - // val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})" val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" val description = call.encryptionStatus + connInfoText chatModel.callCommand.add(WCallCommand.Description(state, description)) diff --git a/cabal.project b/cabal.project index 6801bb923..ee1cf940d 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 281bdebcb82aed4c8c2c08438b9cafc7908183a1 + tag: febf9019e25e3de35f1b005da59e8434e12ae54b source-repository-package type: git diff --git a/packages/simplex-chat-client/typescript/src/response.ts b/packages/simplex-chat-client/typescript/src/response.ts index 0e1b2799b..b50b2e294 100644 --- a/packages/simplex-chat-client/typescript/src/response.ts +++ b/packages/simplex-chat-client/typescript/src/response.ts @@ -86,7 +86,6 @@ export type ChatResponse = | CRGroupUpdated | CRUserContactLinkSubscribed | CRUserContactLinkSubError - | CRNewContactConnection | CRContactConnectionDeleted | CRMessageError | CRChatCmdError @@ -731,12 +730,6 @@ export interface CRUserContactLinkSubError extends CR { chatError: ChatError } -export interface CRNewContactConnection extends CR { - type: "newContactConnection" - user: User - connection: PendingContactConnection -} - export interface CRContactConnectionDeleted extends CR { type: "contactConnectionDeleted" user: User diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d933165f6..a1a7f5930 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."281bdebcb82aed4c8c2c08438b9cafc7908183a1" = "0dly5rnpcnb7mbfxgpxna5xbabk6n0dh5qz53nm4l93gzdy18hpb"; + "https://github.com/simplex-chat/simplexmq.git"."febf9019e25e3de35f1b005da59e8434e12ae54b" = "0rd6cf600978l7xp1sajn9lswml72ms0f55h5q7rxbwpbgx9c3if"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index bf7aa7c6b..b7739faf1 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -124,6 +124,7 @@ library Simplex.Chat.Migrations.M20231107_indexes Simplex.Chat.Migrations.M20231113_group_forward Simplex.Chat.Migrations.M20231114_remote_control + Simplex.Chat.Migrations.M20231126_remote_ctrl_address Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9d48e63d4..1b46a642b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -103,6 +103,7 @@ import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util import Simplex.Messaging.Version import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..)) +import Simplex.RemoteControl.Types (RCCtrlAddress (..)) import System.Exit (ExitCode, exitFailure, exitSuccess) import System.FilePath (takeFileName, ()) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) @@ -1401,7 +1402,6 @@ processChatCommand = \case subMode <- chatReadVar subscriptionMode (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode - toView $ CRNewContactConnection user conn pure $ CRInvitation user cReq conn AddContact incognito -> withUser $ \User {userId} -> processChatCommand $ APIAddContact userId incognito @@ -1431,8 +1431,7 @@ processChatCommand = \case dm <- directMessage $ XInfo profileToSend connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode - toView $ CRNewContactConnection user conn - pure $ CRSentConfirmation user + pure $ CRSentConfirmation user conn APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq Connect incognito aCReqUri@(Just cReqUri) -> withUser $ \user@User {userId} -> do @@ -1964,16 +1963,16 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} - SetLocalDeviceName name -> withUser_ $ chatWriteVar localDeviceName name >> ok_ - ListRemoteHosts -> withUser_ $ CRRemoteHostList <$> listRemoteHosts - SwitchRemoteHost rh_ -> withUser_ $ CRCurrentRemoteHost <$> switchRemoteHost rh_ - StartRemoteHost rh_ -> withUser_ $ do - (remoteHost_, inv@RCSignedInvitation {invitation = RCInvitation {port}}) <- startRemoteHost rh_ - pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv, ctrlPort = show port} - StopRemoteHost rh_ -> withUser_ $ closeRemoteHost rh_ >> ok_ - DeleteRemoteHost rh -> withUser_ $ deleteRemoteHost rh >> ok_ - StoreRemoteFile rh encrypted_ localPath -> withUser_ $ CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath - GetRemoteFile rh rf -> withUser_ $ getRemoteFile rh rf >> ok_ + SetLocalDeviceName name -> chatWriteVar localDeviceName name >> ok_ + ListRemoteHosts -> CRRemoteHostList <$> listRemoteHosts + SwitchRemoteHost rh_ -> CRCurrentRemoteHost <$> switchRemoteHost rh_ + StartRemoteHost rh_ ca_ bp_ -> do + (localAddrs, remoteHost_, inv@RCSignedInvitation {invitation = RCInvitation {port}}) <- startRemoteHost rh_ ca_ bp_ + pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv, ctrlPort = show port, localAddrs} + StopRemoteHost rh_ -> closeRemoteHost rh_ >> ok_ + DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ + StoreRemoteFile rh encrypted_ localPath -> CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath + GetRemoteFile rh rf -> getRemoteFile rh rf >> ok_ ConnectRemoteCtrl inv -> withUser_ $ do (remoteCtrl_, ctrlAppInfo) <- connectRemoteCtrlURI inv pure CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion = currentAppVersion} @@ -2103,8 +2102,7 @@ processChatCommand = \case connect' groupLinkId cReqHash xContactId = do (connId, incognitoProfile, subMode) <- requestContact user incognito cReq xContactId conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode - toView $ CRNewContactConnection user conn - pure $ CRSentInvitation user incognitoProfile + pure $ CRSentInvitation user conn incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> ConnectionRequestUri 'CMContact -> m ChatResponse connectContactViaAddress user incognito ct cReq = withChatLock "connectViaContact" $ do @@ -6189,7 +6187,7 @@ chatCommandP = "/set device name " *> (SetLocalDeviceName <$> textP), "/list remote hosts" $> ListRemoteHosts, "/switch remote host " *> (SwitchRemoteHost <$> ("local" $> Nothing <|> (Just <$> A.decimal))), - "/start remote host " *> (StartRemoteHost <$> ("new" $> Nothing <|> (Just <$> ((,) <$> A.decimal <*> (" multicast=" *> onOffP <|> pure False))))), + "/start remote host " *> (StartRemoteHost <$> ("new" $> Nothing <|> (Just <$> ((,) <$> A.decimal <*> (" multicast=" *> onOffP <|> pure False)))) <*> optional (A.space *> rcCtrlAddressP) <*> optional (" port=" *> A.decimal)), "/stop remote host " *> (StopRemoteHost <$> ("new" $> RHNew <|> RHId <$> A.decimal)), "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), "/store remote file " *> (StoreRemoteFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <* A.space <*> filePath), @@ -6327,6 +6325,8 @@ chatCommandP = (pure Nothing) srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True} + rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> text1P) + text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char adminContactReq :: ConnReqContact diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index cae17e24a..fb2ff89a2 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -41,6 +41,7 @@ import Data.String import Data.Text (Text) import Data.Time (NominalDiffTime, UTCTime) import Data.Version (showVersion) +import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -426,7 +427,7 @@ data ChatCommand | SetGroupTimedMessages GroupName (Maybe Int) | SetLocalDeviceName Text | ListRemoteHosts - | StartRemoteHost (Maybe (RemoteHostId, Bool)) -- Start new or known remote host with optional multicast for known host + | StartRemoteHost (Maybe (RemoteHostId, Bool)) (Maybe RCCtrlAddress) (Maybe Word16) -- Start new or known remote host with optional multicast for known host | SwitchRemoteHost (Maybe RemoteHostId) -- Switch current remote host | StopRemoteHost RHKey -- Shut down a running session | DeleteRemoteHost RemoteHostId -- Unregister remote host and remove its data @@ -469,7 +470,7 @@ allowRemoteCommand = \case APIGetNetworkConfig -> False SetLocalDeviceName _ -> False ListRemoteHosts -> False - StartRemoteHost _ -> False + StartRemoteHost {} -> False SwitchRemoteHost {} -> False StoreRemoteFile {} -> False GetRemoteFile {} -> False @@ -556,8 +557,8 @@ data ChatResponse | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection} | CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection} | CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan} - | CRSentConfirmation {user :: User} - | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} + | CRSentConfirmation {user :: User, connection :: PendingContactConnection} + | CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} @@ -654,11 +655,10 @@ data ChatResponse | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} - | CRNewContactConnection {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} - | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text, ctrlPort :: String} + | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text, ctrlPort :: String, localAddrs :: NonEmpty RCCtrlAddress} | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} | CRNewRemoteHost {remoteHost :: RemoteHostInfo} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} diff --git a/src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs b/src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs new file mode 100644 index 000000000..343e4ca6f --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231126_remote_ctrl_address where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231126_remote_ctrl_address :: Query +m20231126_remote_ctrl_address = + [sql| +ALTER TABLE remote_hosts ADD COLUMN bind_addr TEXT; +ALTER TABLE remote_hosts ADD COLUMN bind_iface TEXT; +ALTER TABLE remote_hosts ADD COLUMN bind_port INTEGER; +|] + +down_m20231126_remote_ctrl_address :: Query +down_m20231126_remote_ctrl_address = + [sql| +ALTER TABLE remote_hosts DROP COLUMN bind_addr; +ALTER TABLE remote_hosts DROP COLUMN bind_iface; +ALTER TABLE remote_hosts DROP COLUMN bind_port; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index bc441ec6f..19b4d7237 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -537,6 +537,10 @@ CREATE TABLE remote_hosts( id_key BLOB NOT NULL, -- long-term/identity signing key host_fingerprint BLOB NOT NULL, -- remote host CA cert fingerprint, set when connected host_dh_pub BLOB NOT NULL -- last session DH key + , + bind_addr TEXT, + bind_iface TEXT, + bind_port INTEGER ); CREATE TABLE remote_controllers( -- e.g., desktops known to a mobile app diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 98d7289f9..b9989d8af 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -26,13 +26,14 @@ import qualified Data.ByteString.Base64.URL as B64U import Data.ByteString.Builder (Builder) import qualified Data.ByteString.Char8 as B import Data.Functor (($>)) -import Data.List.NonEmpty (nonEmpty) +import Data.List.NonEmpty (NonEmpty, nonEmpty) +import qualified Data.List.NonEmpty as L import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) -import Data.Word (Word32) +import Data.Word (Word16, Word32) import qualified Network.HTTP.Types as N import Network.HTTP2.Server (responseStreaming) import qualified Paths_simplex_chat as SC @@ -135,8 +136,8 @@ setNewRemoteHostId sseq rhId = do where err = pure . Left . ChatErrorRemoteHost RHNew -startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation) -startRemoteHost rh_ = do +startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> Maybe RCCtrlAddress -> Maybe Word16 -> m (NonEmpty RCCtrlAddress, Maybe RemoteHostInfo, RCSignedInvitation) +startRemoteHost rh_ rcAddrPrefs_ port_ = do (rhKey, multicast, remoteHost_, pairing) <- case rh_ of Just (rhId, multicast) -> do rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId @@ -144,19 +145,20 @@ startRemoteHost rh_ = do Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing sseq <- startRemoteHostSession rhKey ctrlAppInfo <- mkCtrlAppInfo - (invitation, rchClient, vars) <- handleConnectError rhKey sseq . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast + (localAddrs, invitation, rchClient, vars) <- handleConnectError rhKey sseq . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast rcAddrPrefs_ port_ + let rcAddr_ = L.head localAddrs <$ rcAddrPrefs_ cmdOk <- newEmptyTMVarIO rhsWaitSession <- async $ do rhKeyVar <- newTVarIO rhKey atomically $ takeTMVar cmdOk - handleHostError sseq rhKeyVar $ waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars + handleHostError sseq rhKeyVar $ waitForHostSession remoteHost_ rhKey sseq rcAddr_ rhKeyVar vars let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_} withRemoteHostSession rhKey sseq $ \case RHSessionStarting -> let inv = decodeLatin1 $ strEncode invitation in Right ((), RHSessionConnecting inv rhs) _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState - (remoteHost_, invitation) <$ atomically (putTMVar cmdOk ()) + (localAddrs, remoteHost_, invitation) <$ atomically (putTMVar cmdOk ()) where mkCtrlAppInfo = do deviceName <- chatReadVar localDeviceName @@ -179,8 +181,8 @@ startRemoteHost rh_ = do action `catchChatError` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err)) - waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () - waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars = do + waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> Maybe RCCtrlAddress -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () + waitForHostSession remoteHost_ rhKey sseq rcAddr_ rhKeyVar vars = do (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars let sessionCode = verificationCode sessId withRemoteHostSession rhKey sseq $ \case @@ -194,7 +196,7 @@ startRemoteHost rh_ = do withRemoteHostSession rhKey sseq $ \case RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState - rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName sseq RHSConfirmed {sessionCode} + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' rcAddr_ hostDeviceName sseq RHSConfirmed {sessionCode} let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' @@ -209,17 +211,17 @@ startRemoteHost rh_ = do _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}} - upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> SessionSeq -> RemoteHostSessionState -> m RemoteHostInfo - upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName sseq state = do + upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Maybe RCCtrlAddress -> Text -> SessionSeq -> RemoteHostSessionState -> m RemoteHostInfo + upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ rcAddr_ hostDeviceName sseq state = do KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ case rhi_ of Nothing -> do storePath <- liftIO randomStorePath - rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath pairing' >>= getRemoteHost db + rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath rcAddr_ port_ pairing' >>= getRemoteHost db setNewRemoteHostId sseq remoteHostId pure $ remoteHostInfo rh $ Just state Just rhi@RemoteHostInfo {remoteHostId} -> do - withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' + withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' rcAddr_ port_ pure (rhi :: RemoteHostInfo) {sessionState = Just state} onDisconnected :: ChatMonad m => RHKey -> SessionSeq -> m () onDisconnected rhKey sseq = do @@ -317,8 +319,8 @@ switchRemoteHost rhId_ = do rhi_ <$ chatWriteVar currentRemoteHost rhId_ remoteHostInfo :: RemoteHost -> Maybe RemoteHostSessionState -> RemoteHostInfo -remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionState = - RemoteHostInfo {remoteHostId, storePath, hostDeviceName, sessionState} +remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName, bindAddress_, bindPort_} sessionState = + RemoteHostInfo {remoteHostId, storePath, hostDeviceName, bindAddress_, bindPort_, sessionState} deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost rhId = do diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 8411ceea0..d85dde9e8 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -18,6 +18,7 @@ import qualified Data.Aeson.TH as J import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) +import Data.Word (Word16) import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Types (verificationCode) import qualified Simplex.Messaging.Crypto as C @@ -128,6 +129,8 @@ data RemoteHost = RemoteHost { remoteHostId :: RemoteHostId, hostDeviceName :: Text, storePath :: FilePath, + bindAddress_ :: Maybe RCCtrlAddress, + bindPort_ :: Maybe Word16, hostPairing :: RCHostPairing } @@ -136,6 +139,8 @@ data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, hostDeviceName :: Text, storePath :: FilePath, + bindAddress_ :: Maybe RCCtrlAddress, + bindPort_ :: Maybe Word16, sessionState :: Maybe RemoteHostSessionState } deriving (Show) @@ -158,6 +163,7 @@ data PlatformEncoding deriving (Show, Eq) localEncoding :: PlatformEncoding + #if defined(darwin_HOST_OS) && defined(swiftJSON) localEncoding = PESwift #else diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 7b9ead1b1..31d0525db 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -90,6 +90,7 @@ import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received import Simplex.Chat.Migrations.M20231107_indexes import Simplex.Chat.Migrations.M20231113_group_forward import Simplex.Chat.Migrations.M20231114_remote_control +import Simplex.Chat.Migrations.M20231126_remote_ctrl_address import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -179,7 +180,8 @@ schemaMigrations = ("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received), ("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes), ("20231113_group_forward", m20231113_group_forward, Just down_m20231113_group_forward), - ("20231114_remote_control", m20231114_remote_control, Just down_m20231114_remote_control) + ("20231114_remote_control", m20231114_remote_control, Just down_m20231114_remote_control), + ("20231126_remote_ctrl_address", m20231126_remote_ctrl_address, Just down_m20231126_remote_ctrl_address) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index ec8486037..a88d87a04 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -8,6 +8,8 @@ module Simplex.Chat.Store.Remote where import Control.Monad.Except import Data.Int (Int64) import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8, decodeASCII) +import Data.Word (Word16) import Database.SQLite.Simple (Only (..)) import qualified Database.SQLite.Simple as SQL import Database.SQLite.Simple.QQ (sql) @@ -16,11 +18,12 @@ import Simplex.Chat.Store.Shared import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.RemoteControl.Types import UnliftIO -insertRemoteHost :: DB.Connection -> Text -> FilePath -> RCHostPairing -> ExceptT StoreError IO RemoteHostId -insertRemoteHost db hostDeviceName storePath RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do +insertRemoteHost :: DB.Connection -> Text -> FilePath -> Maybe RCCtrlAddress -> Maybe Word16 -> RCHostPairing -> ExceptT StoreError IO RemoteHostId +insertRemoteHost db hostDeviceName storePath rcAddr_ bindPort_ RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do KnownHostPairing {hostFingerprint, hostDhPubKey} <- maybe (throwError SERemoteHostUnknown) pure kh_ checkConstraint SERemoteHostDuplicateCA . liftIO $ @@ -28,12 +31,14 @@ insertRemoteHost db hostDeviceName storePath RCHostPairing {caKey, caCert, idPri db [sql| INSERT INTO remote_hosts - (host_device_name, store_path, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub) + (host_device_name, store_path, bind_addr, bind_iface, bind_port, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub) VALUES - (?, ?, ?, ?, ?, ?, ?) + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |] - (hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) + (hostDeviceName, storePath, bindAddr_, bindIface_, bindPort_, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) liftIO $ insertedRowId db + where + (bindAddr_, bindIface_) = rcCtrlAddressFields_ rcAddr_ getRemoteHosts :: DB.Connection -> IO [RemoteHost] getRemoteHosts db = @@ -52,27 +57,34 @@ getRemoteHostByFingerprint db fingerprint = remoteHostQuery :: SQL.Query remoteHostQuery = [sql| - SELECT remote_host_id, host_device_name, store_path, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub + SELECT remote_host_id, host_device_name, store_path, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub, bind_iface, bind_addr, bind_port FROM remote_hosts |] -toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject C.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519) -> RemoteHost -toRemoteHost (remoteHostId, hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) = - RemoteHost {remoteHostId, hostDeviceName, storePath, hostPairing} +toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject C.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519, Maybe Text, Maybe Text, Maybe Word16) -> RemoteHost +toRemoteHost (remoteHostId, hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey, ifaceName_, ifaceAddr_, bindPort_) = + RemoteHost {remoteHostId, hostDeviceName, storePath, hostPairing, bindAddress_, bindPort_} where hostPairing = RCHostPairing {caKey, caCert, idPrivKey, knownHost = Just knownHost} knownHost = KnownHostPairing {hostFingerprint, hostDhPubKey} + bindAddress_ = RCCtrlAddress <$> (decodeAddr <$> ifaceAddr_) <*> ifaceName_ + decodeAddr = either (error "Error parsing TransportHost") id . strDecode . encodeUtf8 -updateHostPairing :: DB.Connection -> RemoteHostId -> Text -> C.PublicKeyX25519 -> IO () -updateHostPairing db rhId hostDeviceName hostDhPubKey = +updateHostPairing :: DB.Connection -> RemoteHostId -> Text -> C.PublicKeyX25519 -> Maybe RCCtrlAddress -> Maybe Word16 -> IO () +updateHostPairing db rhId hostDeviceName hostDhPubKey rcAddr_ bindPort_ = DB.execute db [sql| UPDATE remote_hosts - SET host_device_name = ?, host_dh_pub = ? + SET host_device_name = ?, host_dh_pub = ?, bind_addr = ?, bind_iface = ?, bind_port = ? WHERE remote_host_id = ? |] - (hostDeviceName, hostDhPubKey, rhId) + (hostDeviceName, hostDhPubKey, bindAddr_, bindIface_, bindPort_, rhId) + where + (bindAddr_, bindIface_) = rcCtrlAddressFields_ rcAddr_ + +rcCtrlAddressFields_ :: Maybe RCCtrlAddress -> (Maybe Text, Maybe Text) +rcCtrlAddressFields_ = maybe (Nothing, Nothing) $ \RCCtrlAddress {address, interface} -> (Just . decodeASCII $ strEncode address, Just interface) deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d7617edaf..545127b8d 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -64,6 +64,7 @@ import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (bshow, tshow) import Simplex.Messaging.Version hiding (version) +import Simplex.RemoteControl.Types (RCCtrlAddress (..)) import System.Console.ANSI.Types type CurrentTime = UTCTime @@ -162,8 +163,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c CRConnectionPlan u connectionPlan -> ttyUser u $ viewConnectionPlan connectionPlan - CRSentConfirmation u -> ttyUser u ["confirmation sent!"] - CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView + CRSentConfirmation u _ -> ttyUser u ["confirmation sent!"] + CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] @@ -273,7 +274,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRCallInvitations _ -> [] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] - CRNewContactConnection u _ -> ttyUser u [] CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] @@ -285,13 +285,13 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe rhi_ ] CRRemoteHostList hs -> viewRemoteHosts hs - CRRemoteHostStarted {remoteHost_, invitation, ctrlPort} -> + CRRemoteHostStarted {remoteHost_, invitation, localAddrs = RCCtrlAddress {address} :| _, ctrlPort} -> [ plain $ maybe ("new remote host" <> started) (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> show rhId <> started) remoteHost_, "Remote session invitation:", plain invitation ] where - started = " started on port " <> ctrlPort + started = " started on " <> B.unpack (strEncode address) <> ":" <> ctrlPort CRRemoteHostSessionCode {remoteHost_, sessionCode} -> [ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_, "Compare session code with host:", @@ -1712,8 +1712,13 @@ viewRemoteHosts = \case [] -> ["No remote hosts"] hs -> "Remote hosts: " : map viewRemoteHostInfo hs where - viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionState} = - plain $ tshow remoteHostId <> ". " <> hostDeviceName <> maybe "" viewSessionState sessionState + viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionState, bindAddress_, bindPort_} = + plain $ tshow remoteHostId <> ". " <> hostDeviceName <> maybe "" viewSessionState sessionState <> ctrlBinds bindAddress_ bindPort_ + ctrlBinds Nothing Nothing = "" + ctrlBinds rca_ port_ = mconcat [" [", maybe "" rca rca_, maybe "" port port_, "]"] + where + rca RCCtrlAddress {interface, address} = interface <> " " <> decodeLatin1 (strEncode address) + port p = ":" <> tshow p viewSessionState = \case RHSStarting -> " (starting)" RHSConnecting _ -> " (connecting)" diff --git a/stack.yaml b/stack.yaml index 52babb6dc..34c8d3a34 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: 281bdebcb82aed4c8c2c08438b9cafc7908183a1 + commit: febf9019e25e3de35f1b005da59e8434e12ae54b - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index ea6413834..f03e19149 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -38,6 +38,7 @@ remoteTests = describe "Remote" $ do it "connects with stored pairing" remoteHandshakeStoredTest it "connects with multicast discovery" remoteHandshakeDiscoverTest it "refuses invalid client cert" remoteHandshakeRejectTest + it "connects with stored server bindings" storedBindingsTest it "sends messages" remoteMessageTest describe "remote files" $ do it "store/get/send/receive files" remoteStoreFileTest @@ -117,7 +118,7 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil mobileBob ##> "/set device name MobileBob" mobileBob <## "ok" desktop ##> "/start remote host 1" - desktop <##. "remote host 1 started on port " + desktop <##. "remote host 1 started on " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobileBob ##> ("/connect remote ctrl " <> inv) @@ -138,6 +139,37 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil desktop <## "remote host 1 connected" stopMobile mobile desktop +storedBindingsTest :: HasCallStack => FilePath -> IO () +storedBindingsTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do + desktop ##> "/set device name My desktop" + desktop <## "ok" + mobile ##> "/set device name Mobile" + mobile <## "ok" + + desktop ##> "/start remote host new addr=127.0.0.1 iface=lo port=52230" + desktop <##. "new remote host started on 127.0.0.1:52230" -- TODO: show ip? + desktop <## "Remote session invitation:" + inv <- getTermLine desktop + + mobile ##> ("/connect remote ctrl " <> inv) + mobile <## ("connecting new remote controller: My desktop, v" <> versionNumber) + desktop <## "new remote host connecting" + mobile <## "new remote controller connected" + verifyRemoteCtrl mobile desktop + mobile <## "remote controller 1 session started with My desktop" + desktop <## "new remote host 1 added: Mobile" + desktop <## "remote host 1 connected" + + desktop ##> "/list remote hosts" + desktop <## "Remote hosts:" + desktop <## "1. Mobile (connected) [lo 127.0.0.1:52230]" + stopDesktop mobile desktop + desktop ##> "/list remote hosts" + desktop <## "Remote hosts:" + desktop <## "1. Mobile [lo 127.0.0.1:52230]" + + -- TODO: more parser tests + remoteMessageTest :: HasCallStack => FilePath -> IO () remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do startRemote mobile desktop @@ -475,7 +507,7 @@ startRemote mobile desktop = do mobile ##> "/set device name Mobile" mobile <## "ok" desktop ##> "/start remote host new" - desktop <##. "new remote host started on port " + desktop <##. "new remote host started on " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) @@ -490,7 +522,7 @@ startRemote mobile desktop = do startRemoteStored :: TestCC -> TestCC -> IO () startRemoteStored mobile desktop = do desktop ##> "/start remote host 1" - desktop <##. "remote host 1 started on port " + desktop <##. "remote host 1 started on " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) @@ -504,7 +536,7 @@ startRemoteStored mobile desktop = do startRemoteDiscover :: TestCC -> TestCC -> IO () startRemoteDiscover mobile desktop = do desktop ##> "/start remote host 1 multicast=on" - desktop <##. "remote host 1 started on port " + desktop <##. "remote host 1 started on " desktop <## "Remote session invitation:" _inv <- getTermLine desktop -- will use multicast instead mobile ##> "/find remote ctrl"