From cb76c8079c819b0f6e024f7f80c97f11f6338463 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 2 Aug 2024 20:23:54 +0100 Subject: [PATCH] core: fix archive export when some filename is not compatible with zip (#4561) * core: fix archive export when some filename is not compatible with zip * update * core, ios * update kotlin apis, ios: add alert to migrate from device --- apps/ios/Shared/Model/SimpleXAPI.swift | 6 ++- .../Shared/Views/Database/DatabaseView.swift | 38 ++++++++++++++++--- .../Database/MigrateToAppGroupView.swift | 8 ++-- .../Views/Migration/MigrateFromDevice.swift | 35 +++++++++++++---- apps/ios/SimpleXChat/APITypes.swift | 7 +++- .../chat/simplex/common/model/SimpleXAPI.kt | 15 +++++--- src/Simplex/Chat.hs | 12 ++++-- src/Simplex/Chat/Archive.hs | 26 +++++++++---- src/Simplex/Chat/Controller.hs | 5 ++- src/Simplex/Chat/View.hs | 1 + 10 files changed, 115 insertions(+), 38 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6d2897338f..e33df24e06 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -279,8 +279,10 @@ func apiGetAppSettings(settings: AppSettings) throws -> AppSettings { throw r } -func apiExportArchive(config: ArchiveConfig) async throws { - try await sendCommandOkResp(.apiExportArchive(config: config)) +func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] { + let r = await chatSendCmd(.apiExportArchive(config: config)) + if case let .archiveExported(archiveErrors) = r { return archiveErrors } + throw r } func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] { diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 58000a7ee7..6b6d3796dc 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -15,6 +15,7 @@ enum DatabaseAlert: Identifiable { case importArchive case archiveImported case archiveImportedWithErrors(archiveErrors: [ArchiveError]) + case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError]) case deleteChat case chatDeleted case deleteLegacyDatabase @@ -29,6 +30,7 @@ enum DatabaseAlert: Identifiable { case .importArchive: return "importArchive" case .archiveImported: return "archiveImported" case .archiveImportedWithErrors: return "archiveImportedWithErrors" + case .archiveExportedWithErrors: return "archiveExportedWithErrors" case .deleteChat: return "deleteChat" case .chatDeleted: return "chatDeleted" case .deleteLegacyDatabase: return "deleteLegacyDatabase" @@ -265,10 +267,18 @@ struct DatabaseView: View { title: Text("Chat database imported"), message: Text("Restart the app to use imported chat database") ) - case .archiveImportedWithErrors: + case let .archiveImportedWithErrors(errs): return Alert( title: Text("Chat database imported"), - message: Text("Restart the app to use imported chat database") + Text("\n") + Text("Some non-fatal errors occurred during import - you may see Chat console for more details.") + message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs) + ) + case let .archiveExportedWithErrors(archivePath, errs): + return Alert( + title: Text("Chat database exported"), + message: Text("You may save the exported archive.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs), + dismissButton: .default(Text("Continue")) { + showShareSheet(items: [archivePath]) + } ) case .deleteChat: return Alert( @@ -349,9 +359,16 @@ struct DatabaseView: View { progressIndicator = true Task { do { - let archivePath = try await exportChatArchive() - showShareSheet(items: [archivePath]) - await MainActor.run { progressIndicator = false } + let (archivePath, archiveErrors) = try await exportChatArchive() + if archiveErrors.isEmpty { + showShareSheet(items: [archivePath]) + await MainActor.run { progressIndicator = false } + } else { + await MainActor.run { + alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors) + progressIndicator = false + } + } } catch let error { await MainActor.run { alert = .error(title: "Error exporting chat database", error: responseError(error)) @@ -486,6 +503,17 @@ struct DatabaseView: View { } } +func archiveErrorsText(_ errs: [ArchiveError]) -> Text { + return Text("\n" + errs.map(showArchiveError).joined(separator: "\n")) + + func showArchiveError(_ err: ArchiveError) -> String { + switch err { + case let .import(importError): importError + case let .fileError(file, fileError): "\(file): \(fileError)" + } + } +} + func stopChatAsync() async throws { try await apiStopChat() ChatReceiver.shared.stop() diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift index 6d3026f11f..e79f24c6d9 100644 --- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift +++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift @@ -190,7 +190,7 @@ struct MigrateToAppGroupView: View { do { try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) - try await apiExportArchive(config: config) + _ = try await apiExportArchive(config: config) await MainActor.run { setV3DBMigration(.exported) } } catch let error { await MainActor.run { @@ -222,7 +222,7 @@ struct MigrateToAppGroupView: View { } } -func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL { +func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [ArchiveError]) { let archiveTime = Date.now let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted)) let archiveName = "simplex-chat.\(ts).zip" @@ -233,13 +233,13 @@ func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL { try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) } try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) - try await apiExportArchive(config: config) + let errs = try await apiExportArchive(config: config) if storagePath == nil { deleteOldArchive() UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME) chatArchiveTimeDefault.set(archiveTime) } - return archivePath + return (archivePath, errs) } func deleteOldArchive() { diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 028a6d179f..9cc229ba80 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -32,6 +32,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable { case keychainError(_ title: LocalizedStringKey = "Keychain error") case databaseError(_ title: LocalizedStringKey = "Database error", message: String) case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String) + case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError]) case error(title: LocalizedStringKey, error: String = "") @@ -45,6 +46,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable { case .keychainError: return "keychainError" case let .databaseError(title, message): return "\(title) \(message)" case let .unknownError(title, message): return "\(title) \(message)" + case let .archiveExportedWithErrors(path, _): return "archiveExportedWithErrors \(path)" case let .error(title, _): return "error \(title)" } @@ -166,6 +168,14 @@ struct MigrateFromDevice: View { return Alert(title: Text(title), message: Text(message)) case let .unknownError(title, message): return Alert(title: Text(title), message: Text(message)) + case let .archiveExportedWithErrors(archivePath, errs): + return Alert( + title: Text("Chat database exported"), + message: Text("You may migrate the exported database.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs), + dismissButton: .default(Text("Continue")) { + Task { await uploadArchive(path: archivePath) } + } + ) case let .error(title, error): return Alert(title: Text(title), message: Text(error)) } @@ -449,15 +459,12 @@ struct MigrateFromDevice: View { Task { do { try? FileManager.default.createDirectory(at: getMigrationTempFilesDirectory(), withIntermediateDirectories: true) - let archivePath = try await exportChatArchive(getMigrationTempFilesDirectory()) - if let attrs = try? FileManager.default.attributesOfItem(atPath: archivePath.path), - let totalBytes = attrs[.size] as? Int64 { - await MainActor.run { - migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil) - } + let (archivePath, errs) = try await exportChatArchive(getMigrationTempFilesDirectory()) + if errs.isEmpty { + await uploadArchive(path: archivePath) } else { await MainActor.run { - alert = .error(title: "Exported file doesn't exist") + alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: errs) migrationState = .uploadConfirmation } } @@ -469,6 +476,20 @@ struct MigrateFromDevice: View { } } } + + private func uploadArchive(path archivePath: URL) async { + if let attrs = try? FileManager.default.attributesOfItem(atPath: archivePath.path), + let totalBytes = attrs[.size] as? Int64 { + await MainActor.run { + migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil) + } + } else { + await MainActor.run { + alert = .error(title: "Exported file doesn't exist") + migrationState = .uploadConfirmation + } + } + } private func initTemporaryDatabase() -> (chat_ctrl, User)? { let (status, ctrl) = chatInitTemporaryDatabase(url: tempDatabaseUrl) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 796554a839..17b4c2d6ad 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -689,6 +689,7 @@ public enum ChatResponse: Decodable, Error { case agentSubsSummary(user: UserRef, subsSummary: SMPServerSubs) case chatCmdError(user_: UserRef?, chatError: ChatError) case chatError(user_: UserRef?, chatError: ChatError) + case archiveExported(archiveErrors: [ArchiveError]) case archiveImported(archiveErrors: [ArchiveError]) case appSettings(appSettings: AppSettings) @@ -851,6 +852,7 @@ public enum ChatResponse: Decodable, Error { case .agentSubsSummary: return "agentSubsSummary" case .chatCmdError: return "chatCmdError" case .chatError: return "chatError" + case .archiveExported: return "archiveExported" case .archiveImported: return "archiveImported" case .appSettings: return "appSettings" } @@ -1021,6 +1023,7 @@ public enum ChatResponse: Decodable, Error { case let .agentSubsSummary(u, subsSummary): return withUser(u, String(describing: subsSummary)) case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) + case let .archiveExported(archiveErrors): return String(describing: archiveErrors) case let .archiveImported(archiveErrors): return String(describing: archiveErrors) case let .appSettings(appSettings): return String(describing: appSettings) } @@ -2036,8 +2039,8 @@ public enum SMPAgentError: Decodable, Hashable { } public enum ArchiveError: Decodable, Hashable { - case `import`(chatError: ChatError) - case importFile(file: String, chatError: ChatError) + case `import`(importError: String) + case fileError(file: String, fileError: String) } public enum RemoteCtrlError: Decodable, Hashable { 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 64a75c03ce..2ac6886eb1 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 @@ -782,9 +782,9 @@ object ChatController { throw Exception("failed to get app settings: ${r.responseType} ${r.details}") } - suspend fun apiExportArchive(config: ArchiveConfig) { + suspend fun apiExportArchive(config: ArchiveConfig): List { val r = sendCmd(null, CC.ApiExportArchive(config)) - if (r is CR.CmdOk) return + if (r is CR.ArchiveExported) return r.archiveErrors throw Exception("failed to export archive: ${r.responseType} ${r.details}") } @@ -4882,6 +4882,7 @@ sealed class CR { @Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR() + @Serializable @SerialName("archiveExported") class ArchiveExported(val archiveErrors: List): CR() @Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List): CR() @Serializable @SerialName("appSettings") class AppSettingsR(val appSettings: AppSettings): CR() @Serializable @SerialName("agentSubsTotal") class AgentSubsTotal(val user: UserRef, val subsTotal: SMPServerSubs, val hasSession: Boolean): CR() @@ -5051,6 +5052,7 @@ sealed class CR { is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" + is ArchiveExported -> "archiveExported" is ArchiveImported -> "archiveImported" is AppSettingsR -> "appSettings" is Response -> "* $type" @@ -5235,6 +5237,7 @@ sealed class CR { is CmdOk -> withUser(user, noDetails()) is ChatCmdError -> withUser(user_, chatError.string) is ChatRespError -> withUser(user_, chatError.string) + is ArchiveExported -> "${archiveErrors.map { it.string } }" is ArchiveImported -> "${archiveErrors.map { it.string } }" is AppSettingsR -> json.encodeToString(appSettings) is Response -> json @@ -6040,11 +6043,11 @@ sealed class RCErrorType { @Serializable sealed class ArchiveError { val string: String get() = when (this) { - is ArchiveErrorImport -> "import ${chatError.string}" - is ArchiveErrorImportFile -> "importFile $file ${chatError.string}" + is ArchiveErrorImport -> "import ${importError}" + is ArchiveErrorFile -> "importFile $file ${fileError}" } - @Serializable @SerialName("import") class ArchiveErrorImport(val chatError: ChatError): ArchiveError() - @Serializable @SerialName("importFile") class ArchiveErrorImportFile(val file: String, val chatError: ChatError): ArchiveError() + @Serializable @SerialName("import") class ArchiveErrorImport(val importError: String): ArchiveError() + @Serializable @SerialName("fileError") class ArchiveErrorFile(val file: String, val fileError: String): ArchiveError() } @Serializable diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 4298b60a67..3c634ef0b5 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -124,6 +124,7 @@ import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation ( import Simplex.RemoteControl.Types (RCCtrlAddress (..)) import System.Exit (ExitCode, exitSuccess) import System.FilePath (takeFileName, ()) +import qualified System.FilePath as FP import System.IO (Handle, IOMode (..), SeekMode (..), hFlush) import System.Random (randomRIO) import Text.Read (readMaybe) @@ -677,7 +678,7 @@ processChatCommand' vr = \case chatWriteVar sel $ Just f APISetEncryptLocalFiles on -> chatWriteVar encryptLocalFiles on >> ok_ SetContactMergeEnabled onOff -> chatWriteVar contactMergeEnabled onOff >> ok_ - APIExportArchive cfg -> checkChatStopped $ lift (exportArchive cfg) >> ok_ + APIExportArchive cfg -> checkChatStopped $ CRArchiveExported <$> lift (exportArchive cfg) ExportArchive -> do ts <- liftIO getCurrentTime let filePath = "simplex-chat." <> formatTime defaultTimeLocale "%FT%H%M%SZ" ts <> ".zip" @@ -5207,8 +5208,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure () processFileInvitation :: Maybe FileInvitation -> MsgContent -> (DB.Connection -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer) -> CM (Maybe (RcvFileTransfer, CIFile 'MDRcv)) - processFileInvitation fInv_ mc createRcvFT = forM fInv_ $ \fInv@FileInvitation {fileName, fileSize} -> do + processFileInvitation fInv_ mc createRcvFT = forM fInv_ $ \fInv' -> do ChatConfig {fileChunkSize} <- asks config + let fInv@FileInvitation {fileName, fileSize} = mkValidFileInvitation fInv' inline <- receiveInlineMode fInv (Just mc) fileChunkSize ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP @@ -5224,6 +5226,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = fileSource = (`CryptoFile` cryptoArgs) <$> filePath pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) + mkValidFileInvitation :: FileInvitation -> FileInvitation + mkValidFileInvitation fInv@FileInvitation {fileName} = fInv {fileName = FP.makeValid $ FP.takeFileName fileName} + messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> CM () messageUpdate ct@Contact {contactId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do updateRcvChatItem `catchCINotFound` \_ -> do @@ -5463,8 +5468,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> CM () - processFileInvitation' ct fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do + processFileInvitation' ct fInv' msg@RcvMessage {sharedMsgId_} msgMeta = do ChatConfig {fileChunkSize} <- asks config + let fInv@FileInvitation {fileName, fileSize} = mkValidFileInvitation fInv' inline <- receiveInlineMode fInv Nothing fileChunkSize RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 01897de791..218d1e1f2e 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -52,18 +52,22 @@ archiveAssetsFolder = "simplex_v1_assets" wallpapersFolder :: String wallpapersFolder = "wallpapers" -exportArchive :: ArchiveConfig -> CM' () +exportArchive :: ArchiveConfig -> CM' [ArchiveError] exportArchive cfg@ArchiveConfig {archivePath, disableCompression} = withTempDir cfg "simplex-chat." $ \dir -> do StorageFiles {chatStore, agentStore, filesPath, assetsPath} <- storageFiles copyFile (dbFilePath chatStore) $ dir archiveChatDbFile copyFile (dbFilePath agentStore) $ dir archiveAgentDbFile - forM_ filesPath $ \fp -> - copyDirectoryFiles fp $ dir archiveFilesFolder + errs <- + forM filesPath $ \fp -> + copyValidDirectoryFiles entrySelectorError fp $ dir archiveFilesFolder forM_ assetsPath $ \fp -> copyDirectoryFiles (fp wallpapersFolder) $ dir archiveAssetsFolder wallpapersFolder let method = if disableCompression == Just True then Z.Store else Z.Deflate Z.createArchive archivePath $ Z.packDirRecur method Z.mkEntrySelector dir + pure $ fromMaybe [] errs + where + entrySelectorError f = (Z.mkEntrySelector f $> Nothing) `E.catchAny` (pure . Just . show) importArchive :: ArchiveConfig -> CM' [ArchiveError] importArchive cfg@ArchiveConfig {archivePath} = @@ -85,7 +89,7 @@ importArchive cfg@ArchiveConfig {archivePath} = (doesDirectoryExist fromDir) (copyDirectoryFiles fromDir fp) (pure []) - `E.catch` \(e :: E.SomeException) -> pure [AEImport . ChatError . CEException $ show e] + `E.catch` \(e :: E.SomeException) -> pure [AEImport $ show e] _ -> pure [] withTempDir :: ArchiveConfig -> (String -> (FilePath -> CM' a) -> CM' a) @@ -94,14 +98,22 @@ withTempDir cfg = case parentTempDirectory (cfg :: ArchiveConfig) of _ -> withSystemTempDirectory copyDirectoryFiles :: FilePath -> FilePath -> CM' [ArchiveError] -copyDirectoryFiles fromDir toDir = do +copyDirectoryFiles fromDir toDir = copyValidDirectoryFiles (\_ -> pure Nothing) fromDir toDir + +copyValidDirectoryFiles :: (FilePath -> IO (Maybe String)) -> FilePath -> FilePath -> CM' [ArchiveError] +copyValidDirectoryFiles isFileError fromDir toDir = do createDirectoryIfMissing True toDir fs <- listDirectory fromDir foldM copyFileCatchError [] fs where copyFileCatchError fileErrs f = - (copyDirectoryFile f $> fileErrs) - `E.catch` \(e :: E.SomeException) -> pure (AEImportFile f (ChatError . CEException $ show e) : fileErrs) + liftIO (isFileError f) >>= \case + Nothing -> + (copyDirectoryFile f $> fileErrs) + `E.catch` \(e :: E.SomeException) -> addErr $ show e + Just e -> addErr e + where + addErr e = pure $ AEFileError f e : fileErrs copyDirectoryFile f = do let fn = takeFileName f f' = fromDir fn diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 59b91dc1c1..18b6c694e4 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -775,6 +775,7 @@ data ChatResponse | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} | CRChatError {user_ :: Maybe User, chatError :: ChatError} | CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} + | CRArchiveExported {archiveErrors :: [ArchiveError]} | CRArchiveImported {archiveErrors :: [ArchiveError]} | CRAppSettings {appSettings :: AppSettings} | CRTimedAction {action :: String, durationMilliseconds :: Int64} @@ -1250,8 +1251,8 @@ data RemoteCtrlStopReason deriving (Show, Exception) data ArchiveError - = AEImport {chatError :: ChatError} - | AEImportFile {file :: String, chatError :: ChatError} + = AEImport {importError :: String} + | AEFileError {file :: String, fileError :: String} deriving (Show, Exception) -- | Host (mobile) side of transport to process remote commands and forward notifications diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 545f8c08e4..7074d3e6a8 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -408,6 +408,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError True logLevel testView e CRChatError u e -> ttyUser' u $ viewChatError False logLevel testView e CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError False logLevel testView) errs + CRArchiveExported archiveErrs -> if null archiveErrs then ["ok"] else ["archive export errors: " <> plain (show archiveErrs)] CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRAppSettings as -> ["app settings: " <> viewJSON as] CRTimedAction _ _ -> []