mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 17:58:47 +00:00
Follow-up to "Recognise wrong QR code type in every scanner", from an iOS review of that commit. - ScanProtocolServer: a rejected scan now alerts in place instead of dismissing, but the sheet had no way out - move its title into the nav bar and add a Cancel button. Fixes the preview, which needs AppTheme now that the themed background is applied inside the view. - MigrateToDevice: classify the pasted link off the main thread, as the Kotlin paste path already does - pasted text is unbounded and classifying a file link parses its YAML description. Guard the result, as the sheet can be dismissed while classifying. - Relay message named the Chat relay screen but not that the address is only kept once the relay is tested, so following it lost the pasted address to an "Invalid relay name!" alert. The migration message named a label that only exists on desktop. - ContentView: the /r deep link showed the relay alert without the guidance added to the other call site; both literals were identical before. - Document WrongQRCode.swift per apps/ios/CODE.md, and refresh the specs and product docs that quote the changed messages.
430 lines
13 KiB
Swift
430 lines
13 KiB
Swift
//
|
|
// API.swift
|
|
// SimpleX NSE
|
|
//
|
|
// Created by Evgeny on 26/04/2022.
|
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
private var chatController: chat_ctrl?
|
|
|
|
private var migrationResult: (Bool, DBMigrationResult)?
|
|
|
|
public func hasChatCtrl() -> Bool {
|
|
chatController != nil
|
|
}
|
|
|
|
public func getChatCtrl() -> chat_ctrl {
|
|
if let controller = chatController { return controller }
|
|
fatalError("chat controller not initialized")
|
|
}
|
|
|
|
public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: MigrationConfirmation? = nil, backgroundMode: Bool = false) -> (Bool, DBMigrationResult) {
|
|
if let res = migrationResult { return res }
|
|
let dbPath = getAppDatabasePath().path
|
|
var dbKey = ""
|
|
let useKeychain = storeDBPassphraseGroupDefault.get()
|
|
logger.debug("chatMigrateInit uses keychain: \(useKeychain)")
|
|
if let key = useKey {
|
|
dbKey = key
|
|
} else if useKeychain {
|
|
if !hasDatabase() {
|
|
logger.debug("chatMigrateInit generating a random DB key")
|
|
dbKey = randomDatabasePassword()
|
|
initialRandomDBPassphraseGroupDefault.set(true)
|
|
} else if let key = kcDatabasePassword.get() {
|
|
dbKey = key
|
|
}
|
|
}
|
|
let confirm = confirmMigrations ?? defaultMigrationConfirmation()
|
|
logger.debug("chatMigrateInit DB path: \(dbPath), confirm: \(confirm.rawValue)")
|
|
// logger.debug("chatMigrateInit DB key: \(dbKey)")
|
|
var cPath = dbPath.cString(using: .utf8)!
|
|
var cKey = dbKey.cString(using: .utf8)!
|
|
var cConfirm = confirm.rawValue.cString(using: .utf8)!
|
|
// the last parameter of chat_migrate_init is used to return the pointer to chat controller
|
|
let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, backgroundMode ? 1 : 0, &chatController)!
|
|
let dbRes = dbMigrationResult(dataFromCString(cjson))
|
|
let encrypted = dbKey != ""
|
|
let keychainErr = dbRes == .ok && useKeychain && encrypted && !kcDatabasePassword.set(dbKey)
|
|
let result = (encrypted, keychainErr ? .errorKeychain : dbRes)
|
|
migrationResult = result
|
|
return result
|
|
}
|
|
|
|
public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation: MigrationConfirmation = .error) -> (DBMigrationResult, chat_ctrl?) {
|
|
let dbPath = url.path
|
|
let dbKey = key ?? randomDatabasePassword()
|
|
logger.debug("chatInitTemporaryDatabase path: \(dbPath)")
|
|
var temporaryController: chat_ctrl? = nil
|
|
var cPath = dbPath.cString(using: .utf8)!
|
|
var cKey = dbKey.cString(using: .utf8)!
|
|
var cConfirm = confirmation.rawValue.cString(using: .utf8)!
|
|
let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &temporaryController)!
|
|
return (dbMigrationResult(dataFromCString(cjson)), temporaryController)
|
|
}
|
|
|
|
public func chatInitControllerRemovingDatabases() {
|
|
let dbPath = getAppDatabasePath().path
|
|
let fm = FileManager.default
|
|
// Remove previous databases, otherwise, can be .errorNotADatabase with nil controller
|
|
try? fm.removeItem(atPath: dbPath + CHAT_DB)
|
|
try? fm.removeItem(atPath: dbPath + AGENT_DB)
|
|
|
|
let dbKey = randomDatabasePassword()
|
|
logger.debug("chatInitControllerRemovingDatabases path: \(dbPath)")
|
|
var cPath = dbPath.cString(using: .utf8)!
|
|
var cKey = dbKey.cString(using: .utf8)!
|
|
var cConfirm = MigrationConfirmation.error.rawValue.cString(using: .utf8)!
|
|
chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &chatController)
|
|
|
|
// We need only controller, not databases
|
|
try? fm.removeItem(atPath: dbPath + CHAT_DB)
|
|
try? fm.removeItem(atPath: dbPath + AGENT_DB)
|
|
}
|
|
|
|
|
|
public func chatCloseStore() {
|
|
// Prevent crash when exiting the app with already closed store (for example, after changing a database passpharase)
|
|
guard hasChatCtrl() else {
|
|
logger.error("chatCloseStore: already closed, chatCtrl is nil")
|
|
return
|
|
}
|
|
let err = fromCString(chat_close_store(getChatCtrl()))
|
|
if err != "" {
|
|
logger.error("chatCloseStore error: \(err)")
|
|
}
|
|
}
|
|
|
|
public func chatReopenStore() {
|
|
let err = fromCString(chat_reopen_store(getChatCtrl()))
|
|
if err != "" {
|
|
logger.error("chatReopenStore error: \(err)")
|
|
}
|
|
}
|
|
|
|
public func resetChatCtrl() {
|
|
chatController = nil
|
|
migrationResult = nil
|
|
}
|
|
|
|
// Spec: spec/api.md#sendSimpleXCmd
|
|
@inline(__always)
|
|
public func sendSimpleXCmd<R: ChatAPIResult>(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil, retryNum: Int32 = 0) -> APIResult<R> {
|
|
if let d = sendSimpleXCmdStr(cmd.cmdString, ctrl, retryNum: retryNum) {
|
|
decodeAPIResult(d)
|
|
} else {
|
|
APIResult.error(.invalidJSON(json: nil))
|
|
}
|
|
}
|
|
|
|
@inline(__always)
|
|
public func sendSimpleXCmdStr(_ cmd: String, _ ctrl: chat_ctrl? = nil, retryNum: Int32) -> Data? {
|
|
var c = cmd.cString(using: .utf8)!
|
|
return if let cjson = chat_send_cmd_retry(ctrl ?? getChatCtrl(), &c, retryNum) {
|
|
dataFromCString(cjson)
|
|
} else {
|
|
nil
|
|
}
|
|
}
|
|
|
|
// in microseconds
|
|
public let MESSAGE_TIMEOUT: Int32 = 15_000_000
|
|
|
|
@inline(__always)
|
|
public func recvSimpleXMsg<R: ChatAPIResult>(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> APIResult<R>? {
|
|
if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), messageTimeout),
|
|
let d = dataFromCString(cjson) {
|
|
decodeAPIResult(d)
|
|
} else {
|
|
nil
|
|
}
|
|
}
|
|
|
|
public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? {
|
|
var c = s.cString(using: .utf8)!
|
|
if let cjson = chat_parse_markdown(&c) {
|
|
if let d = dataFromCString(cjson) {
|
|
do {
|
|
let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d)
|
|
return r.formattedText
|
|
} catch {
|
|
logger.error("parseSimpleXMarkdown jsonDecoder.decode error: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func chatJsonLength(_ s: String) -> Int {
|
|
var c = s.cString(using: .utf8)!
|
|
return Int(chat_json_length(&c))
|
|
}
|
|
|
|
struct ParsedMarkdown: Decodable {
|
|
var formattedText: [FormattedText]?
|
|
}
|
|
|
|
public func parseServerAddress(_ s: String) -> ServerAddress? {
|
|
var c = s.cString(using: .utf8)!
|
|
if let cjson = chat_parse_server(&c) {
|
|
if let d = dataFromCString(cjson) {
|
|
do {
|
|
let r = try jsonDecoder.decode(ParsedServerAddress.self, from: d)
|
|
return r.serverAddress
|
|
} catch {
|
|
logger.error("parseServerAddress jsonDecoder.decode error: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
struct ParsedServerAddress: Decodable {
|
|
var serverAddress: ServerAddress?
|
|
var parseError: String
|
|
}
|
|
|
|
public enum ScannedLinkType: Decodable {
|
|
case connection(linkType: SimplexLinkType)
|
|
case server
|
|
case fileDescription
|
|
case desktopCtrl
|
|
case verificationCode
|
|
}
|
|
|
|
struct CheckedLink: Decodable {
|
|
var linkType: ScannedLinkType?
|
|
}
|
|
|
|
public func checkLink(_ s: String) -> ScannedLinkType? {
|
|
var c = s.cString(using: .utf8)!
|
|
if let cjson = chat_check_link(&c) {
|
|
if let d = dataFromCString(cjson) {
|
|
do {
|
|
return try jsonDecoder.decode(CheckedLink.self, from: d).linkType
|
|
} catch {
|
|
logger.error("checkLink jsonDecoder.decode error: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// nonisolated async, so callers on the main actor offload to the global executor:
|
|
// pasted text is unbounded, and classifying a file link parses its YAML description
|
|
public func checkLinkAsync(_ s: String) async -> ScannedLinkType? {
|
|
checkLink(s)
|
|
}
|
|
|
|
public func parseSanitizeUri(_ s: String, safe: Bool) -> ParsedUri? {
|
|
var c = s.cString(using: .utf8)!
|
|
if let cjson = chat_parse_uri(&c, safe ? 1 : 0) {
|
|
if let d = dataFromCString(cjson) {
|
|
do {
|
|
return try jsonDecoder.decode(ParsedUri.self, from: d)
|
|
} catch {
|
|
logger.error("parseSanitizeUri jsonDecoder.decode error: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public struct ParsedUri: Decodable {
|
|
public var uriInfo: UriInfo?
|
|
public var parseError: String
|
|
}
|
|
|
|
public struct UriInfo: Decodable {
|
|
public var scheme: String
|
|
public var sanitized: String?
|
|
}
|
|
|
|
@inline(__always)
|
|
public func fromCString(_ c: UnsafeMutablePointer<CChar>) -> String {
|
|
let s = String.init(cString: c)
|
|
free(c)
|
|
return s
|
|
}
|
|
|
|
@inline(__always)
|
|
public func dataFromCString(_ c: UnsafeMutablePointer<CChar>) -> Data? {
|
|
let len = strlen(c)
|
|
if len > 0 {
|
|
return Data(bytesNoCopy: c, count: len, deallocator: .free)
|
|
} else {
|
|
free(c)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@inline(__always)
|
|
public func dataToString(_ d: Data?) -> String {
|
|
if let d {
|
|
String(data: d, encoding: .utf8) ?? "invalid string"
|
|
} else {
|
|
"no data"
|
|
}
|
|
}
|
|
|
|
public func decodeUser_(_ jDict: NSDictionary) -> UserRef? {
|
|
if let user_ = jDict["user_"] {
|
|
try? decodeObject(user_ as Any)
|
|
} else {
|
|
nil
|
|
}
|
|
}
|
|
|
|
public func errorJson(_ jDict: NSDictionary) -> Data? {
|
|
if let chatError = jDict["chatError"] {
|
|
serializeJSON(chatError)
|
|
} else {
|
|
serializeJSON(jDict)
|
|
}
|
|
}
|
|
|
|
public func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) {
|
|
let jChatDict = jChat as! NSDictionary
|
|
let chatInfo: ChatInfo = try decodeObject(jChatDict["chatInfo"]!)
|
|
let chatStats: ChatStats = try decodeObject(jChatDict["chatStats"]!)
|
|
let navInfo: NavigationInfo = if let jNavInfo = jNavInfo as? NSDictionary, let jNav = jNavInfo["navInfo"] {
|
|
try decodeObject(jNav)
|
|
} else {
|
|
NavigationInfo()
|
|
}
|
|
let jChatItems = jChatDict["chatItems"] as! NSArray
|
|
let chatItems = jChatItems.map { jCI in
|
|
if let ci: ChatItem = try? decodeObject(jCI) {
|
|
return ci
|
|
}
|
|
return ChatItem.invalidJSON(
|
|
chatDir: decodeProperty(jCI, "chatDir"),
|
|
meta: decodeProperty(jCI, "meta"),
|
|
json: serializeJSON(jCI, options: .prettyPrinted)
|
|
)
|
|
}
|
|
return (ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats), navInfo)
|
|
}
|
|
|
|
@inline(__always)
|
|
public func decodeObject<T: Decodable>(_ obj: Any) throws -> T {
|
|
try jsonDecoder.decode(T.self, from: JSONSerialization.data(withJSONObject: obj))
|
|
}
|
|
|
|
@inline(__always)
|
|
func decodeProperty<T: Decodable>(_ obj: Any, _ prop: NSString) -> T? {
|
|
if let jProp = (obj as? NSDictionary)?[prop] {
|
|
return try? decodeObject(jProp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
@inline(__always)
|
|
func getOWSF(_ obj: NSDictionary, _ prop: NSString) -> (type: String, object: NSDictionary)? {
|
|
if let j = obj[prop] as? NSDictionary, j.count == 1 || j.count == 2 {
|
|
var type = j.allKeys[0] as? String
|
|
if j.count == 2 && type == "_owsf" {
|
|
type = j.allKeys[1] as? String
|
|
}
|
|
if let type {
|
|
return (type, j)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
@inline(__always)
|
|
public func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) -> Data? {
|
|
if let d = try? JSONSerialization.data(withJSONObject: obj, options: options) {
|
|
dataPrefix(d)
|
|
} else {
|
|
nil
|
|
}
|
|
}
|
|
|
|
let MAX_JSON_VIEW_LENGTH = 2048
|
|
|
|
@inline(__always)
|
|
public func dataPrefix(_ d: Data) -> Data {
|
|
d.count > MAX_JSON_VIEW_LENGTH
|
|
? Data(d.prefix(MAX_JSON_VIEW_LENGTH))
|
|
: d
|
|
}
|
|
|
|
public func responseError(_ err: Error) -> String {
|
|
if let e = err as? ChatError {
|
|
chatErrorString(e)
|
|
} else {
|
|
String(describing: err)
|
|
}
|
|
}
|
|
|
|
public func chatErrorString(_ err: ChatError) -> String {
|
|
switch err {
|
|
case let .invalidJSON(json): dataToString(json)
|
|
case let .unexpectedResult(type): "unexpected result: \(type)"
|
|
default: String(describing: err)
|
|
}
|
|
}
|
|
|
|
public enum DBMigrationResult: Decodable, Equatable {
|
|
case ok
|
|
case invalidConfirmation
|
|
case errorNotADatabase(dbFile: String)
|
|
case errorMigration(dbFile: String, migrationError: MigrationError)
|
|
case errorSQL(dbFile: String, migrationSQLError: String)
|
|
case errorKeychain
|
|
case unknown(json: String)
|
|
}
|
|
|
|
public enum MigrationConfirmation: String {
|
|
case yesUp
|
|
case yesUpDown
|
|
case error
|
|
}
|
|
|
|
public func defaultMigrationConfirmation() -> MigrationConfirmation {
|
|
confirmDBUpgradesGroupDefault.get() ? .error : .yesUp
|
|
}
|
|
|
|
public enum MigrationError: Decodable, Equatable {
|
|
case upgrade(upMigrations: [UpMigration])
|
|
case downgrade(downMigrations: [String])
|
|
case migrationError(mtrError: MTRError)
|
|
}
|
|
|
|
public struct UpMigration: Decodable, Equatable {
|
|
public var upName: String
|
|
// public var withDown: Bool
|
|
}
|
|
|
|
public func downMigrationWarnings(_ downMigrations: [String]) -> [String] {
|
|
let warnings: [(String, String)] = [
|
|
("20260222_chat_relays", NSLocalizedString("If you joined or created channels, they will stop working permanently.", comment: "down migration warning"))
|
|
]
|
|
return warnings.compactMap { (key, message) in
|
|
downMigrations.contains(key) ? message : nil
|
|
}
|
|
}
|
|
|
|
public enum MTRError: Decodable, Equatable {
|
|
case noDown(dbMigrations: [String])
|
|
case different(appMigration: String, dbMigration: String)
|
|
}
|
|
|
|
func dbMigrationResult(_ d: Data?) -> DBMigrationResult {
|
|
if let d {
|
|
do {
|
|
return try jsonDecoder.decode(DBMigrationResult.self, from: d)
|
|
} catch let error {
|
|
logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
|
|
return .unknown(json: dataToString(d))
|
|
}
|
|
} else {
|
|
return .unknown(json: "no data")
|
|
}
|
|
}
|