core: update api (#3221)
This commit is contained in:
parent
f5e9bd4f8b
commit
41b86e07f1
@ -886,9 +886,9 @@ func startRemoteCtrl() async throws {
|
||||
try await sendCommandOkResp(.startRemoteCtrl)
|
||||
}
|
||||
|
||||
func registerRemoteCtrl(_ remoteCtrlOOB: RemoteCtrlOOB) async throws -> Int64 {
|
||||
func registerRemoteCtrl(_ remoteCtrlOOB: RemoteCtrlOOB) async throws -> RemoteCtrlInfo {
|
||||
let r = await chatSendCmd(.registerRemoteCtrl(remoteCtrlOOB: remoteCtrlOOB))
|
||||
if case let .remoteCtrlRegistered(rcId) = r { return rcId }
|
||||
if case let .remoteCtrlRegistered(rcInfo) = r { return rcInfo }
|
||||
throw r
|
||||
}
|
||||
|
||||
|
@ -117,6 +117,7 @@ public enum ChatCommand {
|
||||
case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?)
|
||||
case setFileToReceive(fileId: Int64, encrypted: Bool)
|
||||
case cancelFile(fileId: Int64)
|
||||
case setLocalDeviceName(displayName: String)
|
||||
case startRemoteCtrl
|
||||
case registerRemoteCtrl(remoteCtrlOOB: RemoteCtrlOOB)
|
||||
case listRemoteCtrls
|
||||
@ -262,6 +263,7 @@ public enum ChatCommand {
|
||||
return s
|
||||
case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))"
|
||||
case let .cancelFile(fileId): return "/fcancel \(fileId)"
|
||||
case let .setLocalDeviceName(displayName): return "/set device name \(displayName)"
|
||||
case .startRemoteCtrl: return "/start remote ctrl"
|
||||
case let .registerRemoteCtrl(oob): return "/register remote ctrl \(oob.caFingerprint)"
|
||||
case let .acceptRemoteCtrl(rcId): return "/accept remote ctrl \(rcId)"
|
||||
@ -381,6 +383,7 @@ public enum ChatCommand {
|
||||
case .receiveFile: return "receiveFile"
|
||||
case .setFileToReceive: return "setFileToReceive"
|
||||
case .cancelFile: return "cancelFile"
|
||||
case .setLocalDeviceName: return "setLocalDeviceName"
|
||||
case .startRemoteCtrl: return "startRemoteCtrl"
|
||||
case .registerRemoteCtrl: return "registerRemoteCtrl"
|
||||
case .listRemoteCtrls: return "listRemoteCtrls"
|
||||
@ -585,11 +588,11 @@ public enum ChatResponse: Decodable, Error {
|
||||
case newContactConnection(user: UserRef, connection: PendingContactConnection)
|
||||
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
|
||||
case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo])
|
||||
case remoteCtrlRegistered(remoteCtrlId: Int64)
|
||||
case remoteCtrlRegistered(remoteCtrl: RemoteCtrlInfo)
|
||||
case remoteCtrlAnnounce(fingerprint: String)
|
||||
case remoteCtrlFound(remoteCtrl: RemoteCtrl)
|
||||
case remoteCtrlConnecting(remoteCtrlId: Int64, displayName: String)
|
||||
case remoteCtrlConnected(remoteCtrlId: Int64, displayName: String)
|
||||
case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo)
|
||||
case remoteCtrlConnecting(remoteCtrl: RemoteCtrlInfo)
|
||||
case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo)
|
||||
case remoteCtrlStopped
|
||||
case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration])
|
||||
case cmdOk(user: UserRef?)
|
||||
@ -874,11 +877,11 @@ public enum ChatResponse: Decodable, Error {
|
||||
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 .remoteCtrlRegistered(rcId): return "remote ctrl ID: \(rcId)"
|
||||
case let .remoteCtrlRegistered(remoteCtrl): return String(describing: remoteCtrl)
|
||||
case let .remoteCtrlAnnounce(fingerprint): return "fingerprint: \(fingerprint)"
|
||||
case let .remoteCtrlFound(remoteCtrl): return "remote ctrl: \(String(describing: remoteCtrl))"
|
||||
case let .remoteCtrlConnecting(rcId, displayName): return "remote ctrl ID: \(rcId)\nhost displayName: \(displayName)"
|
||||
case let .remoteCtrlConnected(rcId, displayName): return "remote ctrl ID: \(rcId)\nhost displayName: \(displayName)"
|
||||
case let .remoteCtrlFound(remoteCtrl): return String(describing: remoteCtrl)
|
||||
case let .remoteCtrlConnecting(remoteCtrl): return String(describing: remoteCtrl)
|
||||
case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl)
|
||||
case .remoteCtrlStopped: return noDetails
|
||||
case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))"
|
||||
case .cmdOk: return noDetails
|
||||
|
@ -166,6 +166,7 @@ class AppPreferences {
|
||||
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
|
||||
val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0)
|
||||
val customDisappearingMessageTime = mkIntPreference(SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME, 300)
|
||||
val deviceNameForRemoteAccess = mkStrPreference(SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS, "Desktop")
|
||||
|
||||
private fun mkIntPreference(prefName: String, default: Int) =
|
||||
SharedPreference(
|
||||
@ -306,6 +307,7 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
|
||||
private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode"
|
||||
private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime"
|
||||
private const val SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS = "DeviceNameForRemoteAccess"
|
||||
}
|
||||
}
|
||||
|
||||
@ -342,6 +344,11 @@ object ChatController {
|
||||
val users = listUsers()
|
||||
chatModel.users.clear()
|
||||
chatModel.users.addAll(users)
|
||||
val remoteHosts = listRemoteHosts()
|
||||
if (remoteHosts != null) {
|
||||
chatModel.remoteHosts.clear()
|
||||
chatModel.remoteHosts.addAll(remoteHosts)
|
||||
}
|
||||
if (justStarted) {
|
||||
chatModel.currentUser.value = user
|
||||
chatModel.userCreated.value = true
|
||||
@ -432,15 +439,16 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
private fun recvMsg(ctrl: ChatCtrl): CR? {
|
||||
private fun recvMsg(ctrl: ChatCtrl): APIResponse? {
|
||||
val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
|
||||
return if (json == "") {
|
||||
null
|
||||
} else {
|
||||
val r = APIResponse.decodeStr(json).resp
|
||||
val apiResp = APIResponse.decodeStr(json)
|
||||
val r = apiResp.resp
|
||||
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
|
||||
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
|
||||
r
|
||||
apiResp
|
||||
}
|
||||
}
|
||||
|
||||
@ -1327,6 +1335,59 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setLocalDeviceName(displayName: String): Boolean = sendCommandOkResp(CC.SetLocalDeviceName(displayName))
|
||||
|
||||
suspend fun createRemoteHost(): RemoteHostInfo? {
|
||||
val r = sendCmd(CC.CreateRemoteHost())
|
||||
if (r is CR.RemoteHostCreated) return r.remoteHost
|
||||
apiErrorAlert("createRemoteHost", generalGetString(MR.strings.error), r)
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun listRemoteHosts(): List<RemoteHostInfo>? {
|
||||
val r = sendCmd(CC.ListRemoteHosts())
|
||||
if (r is CR.RemoteHostList) return r.remoteHosts
|
||||
apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error), r)
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun startRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StartRemoteHost(rhId))
|
||||
|
||||
suspend fun registerRemoteCtrl(oob: RemoteCtrlOOB): RemoteCtrlInfo? {
|
||||
val r = sendCmd(CC.RegisterRemoteCtrl(oob))
|
||||
if (r is CR.RemoteCtrlRegistered) return r.remoteCtrl
|
||||
apiErrorAlert("registerRemoteCtrl", generalGetString(MR.strings.error), r)
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun listRemoteCtrls(): List<RemoteCtrlInfo>? {
|
||||
val r = sendCmd(CC.ListRemoteCtrls())
|
||||
if (r is CR.RemoteCtrlList) return r.remoteCtrls
|
||||
apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error), r)
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun stopRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StopRemoteHost(rhId))
|
||||
|
||||
suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteHost(rhId))
|
||||
|
||||
suspend fun startRemoteCtrl(): Boolean = sendCommandOkResp(CC.StartRemoteCtrl())
|
||||
|
||||
suspend fun acceptRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.AcceptRemoteCtrl(rcId))
|
||||
|
||||
suspend fun rejectRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.RejectRemoteCtrl(rcId))
|
||||
|
||||
suspend fun stopRemoteCtrl(): Boolean = sendCommandOkResp(CC.StopRemoteCtrl())
|
||||
|
||||
suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteCtrl(rcId))
|
||||
|
||||
private suspend fun sendCommandOkResp(cmd: CC): Boolean {
|
||||
val r = sendCmd(cmd)
|
||||
val ok = r is CR.CmdOk
|
||||
if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error), r)
|
||||
return ok
|
||||
}
|
||||
|
||||
suspend fun apiGetVersion(): CoreVersionInfo? {
|
||||
val r = sendCmd(CC.ShowVersion())
|
||||
return if (r is CR.VersionInfo) {
|
||||
@ -1361,14 +1422,15 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
fun apiErrorAlert(method: String, title: String, r: CR) {
|
||||
private fun apiErrorAlert(method: String, title: String, r: CR) {
|
||||
val errMsg = "${r.responseType}: ${r.details}"
|
||||
Log.e(TAG, "$method bad response: $errMsg")
|
||||
AlertManager.shared.showAlertMsg(title, errMsg)
|
||||
}
|
||||
|
||||
suspend fun processReceivedMsg(r: CR) {
|
||||
private suspend fun processReceivedMsg(apiResp: APIResponse) {
|
||||
lastMsgReceivedTimestamp = System.currentTimeMillis()
|
||||
val r = apiResp.resp
|
||||
chatModel.addTerminalItem(TerminalItem.resp(r))
|
||||
when (r) {
|
||||
is CR.NewContactConnection -> {
|
||||
@ -1674,6 +1736,13 @@ object ChatController {
|
||||
chatModel.updateContactConnectionStats(r.contact, r.ratchetSyncProgress.connectionStats)
|
||||
is CR.GroupMemberRatchetSync ->
|
||||
chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats)
|
||||
is CR.RemoteHostConnected -> {
|
||||
// update
|
||||
chatModel.connectingRemoteHost.value = r.remoteHost
|
||||
}
|
||||
is CR.RemoteHostStopped -> {
|
||||
//
|
||||
}
|
||||
else ->
|
||||
Log.d(TAG , "unsupported event: ${r.responseType}")
|
||||
}
|
||||
@ -1933,6 +2002,7 @@ sealed class CC {
|
||||
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
|
||||
class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC()
|
||||
class CancelFile(val fileId: Long): CC()
|
||||
class SetLocalDeviceName(val displayName: String): CC()
|
||||
class CreateRemoteHost(): CC()
|
||||
class ListRemoteHosts(): CC()
|
||||
class StartRemoteHost(val remoteHostId: Long): CC()
|
||||
@ -2053,13 +2123,14 @@ sealed class CC {
|
||||
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
|
||||
is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}")
|
||||
is CancelFile -> "/fcancel $fileId"
|
||||
is SetLocalDeviceName -> "/set device name $displayName"
|
||||
is CreateRemoteHost -> "/create remote host"
|
||||
is ListRemoteHosts -> "/list remote hosts"
|
||||
is StartRemoteHost -> "/start remote host $remoteHostId"
|
||||
is StopRemoteHost -> "/stop remote host $remoteHostId"
|
||||
is DeleteRemoteHost -> "/delete remote host $remoteHostId"
|
||||
is StartRemoteCtrl -> "/start remote ctrl"
|
||||
is RegisterRemoteCtrl -> "/register remote ctrl ${remoteCtrlOOB.caFingerprint}"
|
||||
is RegisterRemoteCtrl -> "/register remote ctrl ${remoteCtrlOOB.fingerprint}"
|
||||
is AcceptRemoteCtrl -> "/accept remote ctrl $remoteCtrlId"
|
||||
is RejectRemoteCtrl -> "/reject remote ctrl $remoteCtrlId"
|
||||
is ListRemoteCtrls -> "/list remote ctrls"
|
||||
@ -2162,6 +2233,7 @@ sealed class CC {
|
||||
is ApiChatUnread -> "apiChatUnread"
|
||||
is ReceiveFile -> "receiveFile"
|
||||
is CancelFile -> "cancelFile"
|
||||
is SetLocalDeviceName -> "setLocalDeviceName"
|
||||
is CreateRemoteHost -> "createRemoteHost"
|
||||
is ListRemoteHosts -> "listRemoteHosts"
|
||||
is StartRemoteHost -> "startRemoteHost"
|
||||
@ -3246,7 +3318,8 @@ data class RemoteCtrl (
|
||||
|
||||
@Serializable
|
||||
data class RemoteCtrlOOB (
|
||||
val caFingerprint: String
|
||||
val fingerprint: String,
|
||||
val displayName: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@ -3261,6 +3334,7 @@ data class RemoteHostInfo (
|
||||
val remoteHostId: Long,
|
||||
val storePath: String,
|
||||
val displayName: String,
|
||||
val remoteCtrlOOB: RemoteCtrlOOB,
|
||||
val sessionActive: Boolean
|
||||
)
|
||||
|
||||
@ -3277,7 +3351,7 @@ val yaml = Yaml(configuration = YamlConfiguration(
|
||||
))
|
||||
|
||||
@Serializable
|
||||
class APIResponse(val resp: CR, val corr: String? = null) {
|
||||
class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = null) {
|
||||
companion object {
|
||||
fun decodeStr(str: String): APIResponse {
|
||||
return try {
|
||||
@ -3287,48 +3361,35 @@ class APIResponse(val resp: CR, val corr: String? = null) {
|
||||
Log.d(TAG, e.localizedMessage ?: "")
|
||||
val data = json.parseToJsonElement(str).jsonObject
|
||||
val resp = data["resp"]!!.jsonObject
|
||||
val type = resp["type"]?.jsonPrimitive?.content ?: "invalid"
|
||||
val type = resp["type"]?.jsonPrimitive?.contentOrNull ?: "invalid"
|
||||
val corr = data["corr"]?.toString()
|
||||
val remoteHostId = data["remoteHostId"]?.jsonPrimitive?.longOrNull
|
||||
try {
|
||||
if (type == "apiChats") {
|
||||
val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject)
|
||||
val chats: List<Chat> = resp["chats"]!!.jsonArray.map {
|
||||
parseChatData(it)
|
||||
}
|
||||
return APIResponse(
|
||||
resp = CR.ApiChats(user, chats),
|
||||
corr = data["corr"]?.toString()
|
||||
)
|
||||
return APIResponse(CR.ApiChats(user, chats), remoteHostId, corr)
|
||||
} else if (type == "apiChat") {
|
||||
val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject)
|
||||
val chat = parseChatData(resp["chat"]!!)
|
||||
return APIResponse(
|
||||
resp = CR.ApiChat(user, chat),
|
||||
corr = data["corr"]?.toString()
|
||||
)
|
||||
return APIResponse(CR.ApiChat(user, chat), remoteHostId, corr)
|
||||
} else if (type == "chatCmdError") {
|
||||
val userObject = resp["user_"]?.jsonObject
|
||||
val user = runCatching<UserRef?> { json.decodeFromJsonElement(userObject!!) }.getOrNull()
|
||||
return APIResponse(
|
||||
resp = CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))),
|
||||
corr = data["corr"]?.toString()
|
||||
)
|
||||
return APIResponse(CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr)
|
||||
} else if (type == "chatError") {
|
||||
val userObject = resp["user_"]?.jsonObject
|
||||
val user = runCatching<UserRef?> { json.decodeFromJsonElement(userObject!!) }.getOrNull()
|
||||
return APIResponse(
|
||||
resp = CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))),
|
||||
corr = data["corr"]?.toString()
|
||||
)
|
||||
return APIResponse(CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error while parsing chat(s): " + e.stackTraceToString())
|
||||
}
|
||||
APIResponse(
|
||||
resp = CR.Response(type, json.encodeToString(data)),
|
||||
corr = data["corr"]?.toString()
|
||||
)
|
||||
APIResponse(CR.Response(type, json.encodeToString(data)), remoteHostId, corr)
|
||||
} catch(e: Exception) {
|
||||
APIResponse(CR.Invalid(str))
|
||||
APIResponse(CR.Invalid(str), remoteHostId = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3484,17 +3545,17 @@ sealed class 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("remoteHostCreated") class RemoteHostCreated(val remoteHostId: Long, val oobData: RemoteCtrlOOB): CR()
|
||||
@Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHost: RemoteHostInfo): CR()
|
||||
@Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List<RemoteHostInfo>): CR()
|
||||
@Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHostId: Long): CR()
|
||||
@Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR()
|
||||
@Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR()
|
||||
// remote events (mobile)
|
||||
@Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List<RemoteCtrlInfo>): CR()
|
||||
@Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrlId: Long): CR()
|
||||
@Serializable @SerialName("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrl: RemoteCtrlInfo): CR()
|
||||
@Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR()
|
||||
@Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrl): CR()
|
||||
@Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrlId: Long, val displayName: String): CR()
|
||||
@Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrlId: Long, val displayName: String): CR()
|
||||
@Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo): CR()
|
||||
@Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl: RemoteCtrlInfo): CR()
|
||||
@Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR()
|
||||
@Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR()
|
||||
@Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List<UpMigration>, val agentMigrations: List<UpMigration>): CR()
|
||||
@Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR()
|
||||
@ -3767,17 +3828,17 @@ sealed class CR {
|
||||
is CallEnded -> withUser(user, "contact: ${contact.id}")
|
||||
is NewContactConnection -> withUser(user, json.encodeToString(connection))
|
||||
is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection))
|
||||
is RemoteHostCreated -> "remote host ID: $remoteHostId\noobData ${json.encodeToString(oobData)}"
|
||||
is RemoteHostList -> "remote hosts: ${json.encodeToString(remoteHosts)}"
|
||||
is RemoteHostConnected -> "remote host ID: $remoteHostId"
|
||||
is RemoteHostCreated -> json.encodeToString(remoteHost)
|
||||
is RemoteHostList -> json.encodeToString(remoteHosts)
|
||||
is RemoteHostConnected -> json.encodeToString(remoteHost)
|
||||
is RemoteHostStopped -> "remote host ID: $remoteHostId"
|
||||
is RemoteCtrlList -> json.encodeToString(remoteCtrls)
|
||||
is RemoteCtrlRegistered -> "remote ctrl ID: $remoteCtrlId"
|
||||
is RemoteCtrlRegistered -> json.encodeToString(remoteCtrl)
|
||||
is RemoteCtrlAnnounce -> "fingerprint: $fingerprint"
|
||||
is RemoteCtrlFound -> "remote ctrl: ${json.encodeToString(remoteCtrl)}"
|
||||
is RemoteCtrlConnecting -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName"
|
||||
is RemoteCtrlConnected -> "remote ctrl ID: $remoteCtrlId\nhost displayName: $displayName"
|
||||
is RemoteCtrlStopped -> ""
|
||||
is RemoteCtrlFound -> json.encodeToString(remoteCtrl)
|
||||
is RemoteCtrlConnecting -> json.encodeToString(remoteCtrl)
|
||||
is RemoteCtrlConnected -> json.encodeToString(remoteCtrl)
|
||||
is RemoteCtrlStopped -> noDetails()
|
||||
is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" +
|
||||
"chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" +
|
||||
"agent migrations: ${json.encodeToString(agentMigrations.map { it.upName })}"
|
||||
|
@ -70,6 +70,7 @@ import Simplex.Chat.Store.Files
|
||||
import Simplex.Chat.Store.Groups
|
||||
import Simplex.Chat.Store.Messages
|
||||
import Simplex.Chat.Store.Profiles
|
||||
import Simplex.Chat.Store.Remote
|
||||
import Simplex.Chat.Store.Shared
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Preferences
|
||||
@ -1900,7 +1901,7 @@ processChatCommand = \case
|
||||
StopRemoteHost rh -> closeRemoteHostSession rh >> ok_
|
||||
DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_
|
||||
StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) >> ok_
|
||||
RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> registerRemoteCtrl oob
|
||||
RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob)
|
||||
AcceptRemoteCtrl rc -> acceptRemoteCtrl rc >> ok_
|
||||
RejectRemoteCtrl rc -> rejectRemoteCtrl rc >> ok_
|
||||
StopRemoteCtrl -> stopRemoteCtrl >> ok_
|
||||
|
@ -633,14 +633,14 @@ data ChatResponse
|
||||
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
|
||||
| CRRemoteHostCreated {remoteHost :: RemoteHostInfo}
|
||||
| CRRemoteHostList {remoteHosts :: [RemoteHostInfo]}
|
||||
| CRRemoteHostConnected {remoteHostId :: RemoteHostId} -- TODO add displayName
|
||||
| CRRemoteHostConnected {remoteHost :: RemoteHostInfo}
|
||||
| CRRemoteHostStopped {remoteHostId :: RemoteHostId}
|
||||
| CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]}
|
||||
| CRRemoteCtrlRegistered {remoteCtrlId :: RemoteCtrlId}
|
||||
| CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo}
|
||||
| CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation
|
||||
| CRRemoteCtrlFound {remoteCtrl :: RemoteCtrl} -- registered fingerprint, may connect
|
||||
| CRRemoteCtrlConnecting {remoteCtrlId :: RemoteCtrlId, displayName :: Text}
|
||||
| CRRemoteCtrlConnected {remoteCtrlId :: RemoteCtrlId, displayName :: Text}
|
||||
| CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect
|
||||
| CRRemoteCtrlConnecting {remoteCtrl :: RemoteCtrlInfo}
|
||||
| CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo}
|
||||
| CRRemoteCtrlStopped
|
||||
| CRSQLResult {rows :: [Text]}
|
||||
| CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]}
|
||||
@ -693,34 +693,6 @@ logResponseToFile = \case
|
||||
CRMessageError {} -> True
|
||||
_ -> False
|
||||
|
||||
data RemoteCtrlOOB = RemoteCtrlOOB
|
||||
{ caFingerprint :: C.KeyHash,
|
||||
displayName :: Text
|
||||
}
|
||||
deriving (Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON RemoteCtrlOOB where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
data RemoteHostInfo = RemoteHostInfo
|
||||
{ remoteHostId :: RemoteHostId,
|
||||
storePath :: FilePath,
|
||||
displayName :: Text,
|
||||
remoteCtrlOOB :: RemoteCtrlOOB,
|
||||
sessionActive :: Bool
|
||||
}
|
||||
deriving (Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON RemoteHostInfo where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
data RemoteCtrlInfo = RemoteCtrlInfo
|
||||
{ remoteCtrlId :: RemoteCtrlId,
|
||||
displayName :: Text,
|
||||
sessionActive :: Bool
|
||||
}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON RemoteCtrlInfo where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
data ConnectionPlan
|
||||
= CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan}
|
||||
| CPContactAddress {contactAddressPlan :: ContactAddressPlan}
|
||||
|
@ -33,6 +33,7 @@ import Data.Int (Int64)
|
||||
import Data.List.NonEmpty (NonEmpty (..))
|
||||
import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
||||
import qualified Network.HTTP.Types as HTTP
|
||||
@ -93,7 +94,7 @@ startRemoteHost remoteHostId = do
|
||||
M.lookup remoteHostId <$> chatReadVar remoteHostSessions >>= \case
|
||||
Nothing -> logInfo $ "Session already closed for remote host " <> tshow remoteHostId
|
||||
Just _ -> closeRemoteHostSession remoteHostId >> toView (CRRemoteHostStopped remoteHostId)
|
||||
run RemoteHost {storePath, caKey, caCert} = do
|
||||
run rh@RemoteHost {storePath, caKey, caCert} = do
|
||||
finished <- newTVarIO False
|
||||
let parent = (C.signatureKeyPair caKey, caCert)
|
||||
sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session"
|
||||
@ -120,7 +121,9 @@ startRemoteHost remoteHostId = do
|
||||
Nothing -> toViewRemote chatResponse
|
||||
Just localFile -> toViewRemote CRRcvFileComplete {user = ru, chatItem = AChatItem c d i ci {file = Just localFile}}
|
||||
_ -> toViewRemote chatResponse
|
||||
toView CRRemoteHostConnected {remoteHostId}
|
||||
rcName <- chatReadVar localDeviceName
|
||||
-- TODO what sets session active?
|
||||
toView CRRemoteHostConnected {remoteHost = remoteHostInfo rh True rcName}
|
||||
|
||||
sendHello :: (ChatMonad m) => HTTP2Client -> m (Either HTTP2.HTTP2ClientError HTTP2.HTTP2Response)
|
||||
sendHello http = liftIO (HTTP2.sendRequestDirect http req Nothing)
|
||||
@ -155,13 +158,13 @@ cancelRemoteHostSession = \case
|
||||
|
||||
createRemoteHost :: (ChatMonad m) => m RemoteHostInfo
|
||||
createRemoteHost = do
|
||||
let hostDisplayName = "TODO" -- you don't have remote host name here, it will be passed from remote host
|
||||
((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) hostDisplayName
|
||||
let rhName = "TODO" -- you don't have remote host name here, it will be passed from remote host
|
||||
((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) rhName
|
||||
storePath <- liftIO randomStorePath
|
||||
remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath hostDisplayName caKey caCert
|
||||
displayName <- chatReadVar localDeviceName
|
||||
let remoteCtrlOOB = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert, displayName}
|
||||
pure RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive = False}
|
||||
remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath rhName caKey caCert
|
||||
rcName <- chatReadVar localDeviceName
|
||||
let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName}
|
||||
pure RemoteHostInfo {remoteHostId, storePath, displayName = rhName, remoteCtrlOOB, sessionActive = False}
|
||||
|
||||
-- | Generate a random 16-char filepath without / in it by using base64url encoding.
|
||||
randomStorePath :: IO FilePath
|
||||
@ -173,10 +176,13 @@ listRemoteHosts = do
|
||||
rcName <- chatReadVar localDeviceName
|
||||
map (rhInfo active rcName) <$> withStore' getRemoteHosts
|
||||
where
|
||||
rhInfo active rcName RemoteHost {remoteHostId, storePath, displayName, caCert} =
|
||||
let sessionActive = M.member remoteHostId active
|
||||
remoteCtrlOOB = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert, displayName = rcName}
|
||||
in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive}
|
||||
rhInfo active rcName rh@RemoteHost {remoteHostId} =
|
||||
remoteHostInfo rh (M.member remoteHostId active) rcName
|
||||
|
||||
remoteHostInfo :: RemoteHost -> Bool -> Text -> RemoteHostInfo
|
||||
remoteHostInfo RemoteHost {remoteHostId, storePath, displayName, caCert} sessionActive rcName =
|
||||
let remoteCtrlOOB = RemoteCtrlOOB {fingerprint = C.certificateFingerprint caCert, displayName = rcName}
|
||||
in RemoteHostInfo {remoteHostId, storePath, displayName, remoteCtrlOOB, sessionActive}
|
||||
|
||||
deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m ()
|
||||
deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \RemoteHost {storePath} -> do
|
||||
@ -405,13 +411,13 @@ startRemoteCtrl execChatCommand =
|
||||
accepted <- newEmptyTMVarIO
|
||||
supervisor <- async $ do
|
||||
remoteCtrlId <- atomically (readTMVar accepted)
|
||||
withRemoteCtrl remoteCtrlId $ \RemoteCtrl {displayName, fingerprint} -> do
|
||||
withRemoteCtrl remoteCtrlId $ \rc@RemoteCtrl {fingerprint} -> do
|
||||
source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure
|
||||
toView $ CRRemoteCtrlConnecting {remoteCtrlId, displayName}
|
||||
toView $ CRRemoteCtrlConnecting $ remoteCtrlInfo rc False
|
||||
atomically $ writeTVar discovered mempty -- flush unused sources
|
||||
server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand)
|
||||
chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server}
|
||||
toView $ CRRemoteCtrlConnected {remoteCtrlId, displayName}
|
||||
toView $ CRRemoteCtrlConnected $ remoteCtrlInfo rc True
|
||||
_ <- waitCatch server
|
||||
chatWriteVar remoteCtrlSession Nothing
|
||||
toView CRRemoteCtrlStopped
|
||||
@ -436,7 +442,7 @@ discoverRemoteCtrls discovered = Discovery.withListener go
|
||||
withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case
|
||||
Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required
|
||||
Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of
|
||||
Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui "accept" action required
|
||||
Nothing -> toView $ CRRemoteCtrlFound $ remoteCtrlInfo found False -- first-time controller, ui "accept" action required
|
||||
Just False -> pure () -- skipping a rejected item
|
||||
Just True ->
|
||||
chatReadVar remoteCtrlSession >>= \case
|
||||
@ -444,11 +450,6 @@ discoverRemoteCtrls discovered = Discovery.withListener go
|
||||
Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically
|
||||
_nonV4 -> go sock
|
||||
|
||||
registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m RemoteCtrlId
|
||||
registerRemoteCtrl RemoteCtrlOOB {caFingerprint, displayName} = do
|
||||
remoteCtrlId <- withStore' $ \db -> insertRemoteCtrl db displayName caFingerprint
|
||||
pure remoteCtrlId
|
||||
|
||||
listRemoteCtrls :: (ChatMonad m) => m [RemoteCtrlInfo]
|
||||
listRemoteCtrls = do
|
||||
active <-
|
||||
@ -456,9 +457,12 @@ listRemoteCtrls = do
|
||||
$>>= \RemoteCtrlSession {accepted} -> atomically $ tryReadTMVar accepted
|
||||
map (rcInfo active) <$> withStore' getRemoteCtrls
|
||||
where
|
||||
rcInfo active RemoteCtrl {remoteCtrlId, displayName} =
|
||||
let sessionActive = active == Just remoteCtrlId
|
||||
in RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive}
|
||||
rcInfo active rc@RemoteCtrl {remoteCtrlId} =
|
||||
remoteCtrlInfo rc $ active == Just remoteCtrlId
|
||||
|
||||
remoteCtrlInfo :: RemoteCtrl -> Bool -> RemoteCtrlInfo
|
||||
remoteCtrlInfo RemoteCtrl {remoteCtrlId, displayName, fingerprint, accepted} sessionActive =
|
||||
RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted, sessionActive}
|
||||
|
||||
acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ()
|
||||
acceptRemoteCtrl remoteCtrlId = do
|
||||
|
@ -24,6 +24,25 @@ data RemoteHost = RemoteHost
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
data RemoteCtrlOOB = RemoteCtrlOOB
|
||||
{ fingerprint :: C.KeyHash,
|
||||
displayName :: Text
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
$(J.deriveJSON J.defaultOptions ''RemoteCtrlOOB)
|
||||
|
||||
data RemoteHostInfo = RemoteHostInfo
|
||||
{ remoteHostId :: RemoteHostId,
|
||||
storePath :: FilePath,
|
||||
displayName :: Text,
|
||||
remoteCtrlOOB :: RemoteCtrlOOB,
|
||||
sessionActive :: Bool
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
$(J.deriveJSON J.defaultOptions ''RemoteHostInfo)
|
||||
|
||||
type RemoteCtrlId = Int64
|
||||
|
||||
data RemoteCtrl = RemoteCtrl
|
||||
@ -34,4 +53,15 @@ data RemoteCtrl = RemoteCtrl
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
$(J.deriveJSON J.defaultOptions ''RemoteCtrl)
|
||||
$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrl)
|
||||
|
||||
data RemoteCtrlInfo = RemoteCtrlInfo
|
||||
{ remoteCtrlId :: RemoteCtrlId,
|
||||
displayName :: Text,
|
||||
fingerprint :: C.KeyHash,
|
||||
accepted :: Maybe Bool,
|
||||
sessionActive :: Bool
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
$(J.deriveJSON J.defaultOptions {J.omitNothingFields = True} ''RemoteCtrlInfo)
|
||||
|
@ -9,14 +9,15 @@ import Data.Text (Text)
|
||||
import Database.SQLite.Simple (Only (..))
|
||||
import qualified Database.SQLite.Simple as SQL
|
||||
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
|
||||
import Simplex.Chat.Remote.Types (RemoteCtrl (..), RemoteCtrlId, RemoteHost (..), RemoteHostId)
|
||||
import Simplex.Chat.Store.Shared (insertedRowId)
|
||||
import Simplex.Chat.Remote.Types
|
||||
import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow)
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
|
||||
insertRemoteHost :: DB.Connection -> FilePath -> Text -> C.APrivateSignKey -> C.SignedCertificate -> IO RemoteHostId
|
||||
insertRemoteHost db storePath displayName caKey caCert = do
|
||||
DB.execute db "INSERT INTO remote_hosts (store_path, display_name, ca_key, ca_cert) VALUES (?,?,?,?)" (storePath, displayName, caKey, C.SignedObject caCert)
|
||||
fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()"
|
||||
insertedRowId db
|
||||
|
||||
getRemoteHosts :: DB.Connection -> IO [RemoteHost]
|
||||
getRemoteHosts db =
|
||||
@ -37,10 +38,11 @@ toRemoteHost (remoteHostId, storePath, displayName, caKey, C.SignedObject caCert
|
||||
deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO ()
|
||||
deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId)
|
||||
|
||||
insertRemoteCtrl :: DB.Connection -> Text -> C.KeyHash -> IO RemoteCtrlId
|
||||
insertRemoteCtrl db displayName fingerprint = do
|
||||
insertRemoteCtrl :: DB.Connection -> RemoteCtrlOOB -> IO RemoteCtrlInfo
|
||||
insertRemoteCtrl db RemoteCtrlOOB {fingerprint, displayName} = do
|
||||
DB.execute db "INSERT INTO remote_controllers (display_name, fingerprint) VALUES (?,?)" (displayName, fingerprint)
|
||||
fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()"
|
||||
remoteCtrlId <- insertedRowId db
|
||||
pure RemoteCtrlInfo {remoteCtrlId, displayName, fingerprint, accepted = Nothing, sessionActive = False}
|
||||
|
||||
getRemoteCtrls :: DB.Connection -> IO [RemoteCtrl]
|
||||
getRemoteCtrls db =
|
||||
|
@ -264,14 +264,14 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei
|
||||
CRNtfMessages {} -> []
|
||||
CRRemoteHostCreated RemoteHostInfo {remoteHostId, remoteCtrlOOB} -> ("remote host " <> sShow remoteHostId <> " created") : viewRemoteCtrlOOBData remoteCtrlOOB
|
||||
CRRemoteHostList hs -> viewRemoteHosts hs
|
||||
CRRemoteHostConnected rhId -> ["remote host " <> sShow rhId <> " connected"]
|
||||
CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"]
|
||||
CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"]
|
||||
CRRemoteCtrlList cs -> viewRemoteCtrls cs
|
||||
CRRemoteCtrlRegistered rcId -> ["remote controller " <> sShow rcId <> " registered"]
|
||||
CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> ["remote controller " <> sShow rcId <> " registered"]
|
||||
CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint]
|
||||
CRRemoteCtrlFound rc -> ["remote controller found:", viewRemoteCtrl rc]
|
||||
CRRemoteCtrlConnecting rcId rcName -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName]
|
||||
CRRemoteCtrlConnected rcId rcName -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName]
|
||||
CRRemoteCtrlConnecting RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> ["remote controller " <> sShow rcId <> " connecting to " <> plain rcName]
|
||||
CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, displayName = rcName} -> ["remote controller " <> sShow rcId <> " connected, " <> plain rcName]
|
||||
CRRemoteCtrlStopped -> ["remote controller stopped"]
|
||||
CRSQLResult rows -> map plain rows
|
||||
CRSlowSQLQueries {chatQueries, agentQueries} ->
|
||||
@ -1633,8 +1633,8 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo
|
||||
parens s = " (" <> s <> ")"
|
||||
|
||||
viewRemoteCtrlOOBData :: RemoteCtrlOOB -> [StyledString]
|
||||
viewRemoteCtrlOOBData RemoteCtrlOOB {caFingerprint} =
|
||||
["connection code:", plain $ strEncode caFingerprint]
|
||||
viewRemoteCtrlOOBData RemoteCtrlOOB {fingerprint} =
|
||||
["connection code:", plain $ strEncode fingerprint]
|
||||
|
||||
viewRemoteHosts :: [RemoteHostInfo] -> [StyledString]
|
||||
viewRemoteHosts = \case
|
||||
@ -1653,8 +1653,8 @@ viewRemoteCtrls = \case
|
||||
plain $ tshow remoteCtrlId <> ". " <> displayName <> if sessionActive then " (active)" else ""
|
||||
|
||||
-- TODO fingerprint, accepted?
|
||||
viewRemoteCtrl :: RemoteCtrl -> StyledString
|
||||
viewRemoteCtrl RemoteCtrl {remoteCtrlId, displayName} =
|
||||
viewRemoteCtrl :: RemoteCtrlInfo -> StyledString
|
||||
viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, displayName} =
|
||||
plain $ tshow remoteCtrlId <> ". " <> displayName
|
||||
|
||||
viewChatError :: ChatLogLevel -> ChatError -> [StyledString]
|
||||
|
Loading…
Reference in New Issue
Block a user