This commit is contained in:
spaced4ndy
2026-08-12 18:31:55 +04:00
parent 247e327ebe
commit 261e6103f8
22 changed files with 598 additions and 102 deletions
+7
View File
@@ -133,6 +133,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
case apiChangeConnectionUser(connId: Int64, userId: Int64)
case apiConnectPlan(userId: Int64, connLink: String, resolveMode: PlanResolveMode, linkOwnerSig: LinkOwnerSig?)
case apiSendServiceRequest(userId: Int64, target: String, timeoutSec: Double?, requestJSON: String)
case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, verifiedDomain: SimplexDomain?)
case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData, verifiedDomain: SimplexDomain?)
case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64)
@@ -355,6 +356,9 @@ enum ChatCommand: ChatCmdProtocol {
let resolveStr = resolveMode == .unknown ? "" : " resolve=\(resolveMode.rawValue)"
let sigStr = if let linkOwnerSig { " sig=\(encodeJSON(linkOwnerSig))" } else { "" }
return "/_connect plan \(userId) \(connLink)\(resolveStr)\(sigStr)"
case let .apiSendServiceRequest(userId, target, timeoutSec, requestJSON):
let timeoutStr = if let timeoutSec { " timeout=\(timeoutSec)" } else { "" }
return "/_service_request \(userId) \(target)\(timeoutStr) \(requestJSON)"
case let .apiPrepareContact(userId, connLink, contactShortLinkData, verifiedDomain): return "/_prepare contact \(userId) \(connLink.cmdString)\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(contactShortLinkData))"
case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData, verifiedDomain): return "/_prepare group \(userId) \(connLink.cmdString) direct=\(onOff(directLink))\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(groupShortLinkData))"
case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)"
@@ -543,6 +547,7 @@ enum ChatCommand: ChatCmdProtocol {
case .apiSetConnectionIncognito: return "apiSetConnectionIncognito"
case .apiChangeConnectionUser: return "apiChangeConnectionUser"
case .apiConnectPlan: return "apiConnectPlan"
case .apiSendServiceRequest: return "apiSendServiceRequest"
case .apiPrepareContact: return "apiPrepareContact"
case .apiPrepareGroup: return "apiPrepareGroup"
case .apiChangePreparedContactUser: return "apiChangePreparedContactUser"
@@ -821,6 +826,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef)
case connectionPlan(user: UserRef, connLink: CreatedConnLink, planSimplexName: SimplexNameInfo?, otherSimplexName: SimplexNameInfo?, connectionPlan: ConnectionPlan)
case serviceResponse(user: UserRef, responseData: JSONValue)
case newPreparedChat(user: UserRef, chat: ChatData)
case contactUserChanged(user: UserRef, fromContact: Contact, newUser: UserRef, toContact: Contact)
case groupUserChanged(user: UserRef, fromGroup: GroupInfo, newUser: UserRef, toGroup: GroupInfo)
@@ -865,6 +871,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case .connectionIncognitoUpdated: "connectionIncognitoUpdated"
case .connectionUserChanged: "connectionUserChanged"
case .connectionPlan: "connectionPlan"
case .serviceResponse: "serviceResponse"
case .newPreparedChat: "newPreparedChat"
case .contactUserChanged: "contactUserChanged"
case .groupUserChanged: "groupUserChanged"
+21 -4
View File
@@ -298,30 +298,47 @@ class ChatItemDummyModel: ObservableObject {
func sendUpdate() { objectWillChange.send() }
}
// A directory search and a connection can overlap - tapping a result starts a connection while
// the search is still running - so the single progress slot records its owner.
enum ConnectProgressOwner {
case connect
case directorySearch
}
class ConnectProgressManager: ObservableObject {
@Published private var connectInProgress: String? = nil
@Published private var connectProgressByTimeout: Bool = false
private var onCancel: (() -> Void)?
private var owner: ConnectProgressOwner?
static let shared = ConnectProgressManager()
func startConnectProgress(_ text: String, onCancel: (() -> Void)? = nil) {
func startConnectProgress(_ text: String, owner: ConnectProgressOwner = .connect, onCancel: (() -> Void)? = nil) {
connectInProgress = text
self.owner = owner
self.onCancel = onCancel
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.connectProgressByTimeout = self.connectInProgress != nil
}
}
func stopConnectProgress() {
// a late directory search result must not clear the spinner that now belongs to a connection
func stopConnectProgress(_ owner: ConnectProgressOwner = .connect) {
if let current = self.owner, current != owner { return }
connectInProgress = nil
self.owner = nil
onCancel = nil
connectProgressByTimeout = false
}
// a user-initiated cancel, and the takeover in planAndConnect, cancel whatever is running
func cancelConnectProgress() {
onCancel?()
stopConnectProgress()
let cancel = onCancel
owner = nil
onCancel = nil
connectInProgress = nil
connectProgressByTimeout = false
cancel?()
}
var showConnectProgress: String? {
@@ -0,0 +1,73 @@
//
// DirectorySearch.swift
// SimpleX
//
// Created by spaced4ndy on 12.08.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import Foundation
import SimpleXChat
// The directory's contact address, as published in docs/DIRECTORY.md. It must be the short
// link: only that form carries the address DR keys that service requests require, so the full
// links on the What's New cards cannot be substituted here.
let DIRECTORY_SERVICE_LINK = "https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok"
// A service request is a full DR handshake, so it is slower than a local API call; the user
// gets a cancellable spinner while it runs and a retry row if it times out.
let DIRECTORY_SEARCH_TIMEOUT_SEC: Double = 10
struct DirectoryPublicLink: Decodable, Hashable {
var connFullLink: String? = nil
var connShortLink: String? = nil
}
struct DirectoryEntryType: Decodable, Hashable {
var groupType: GroupType? = nil
var summary: GroupSummary
}
struct DirectorySearchEntry: Decodable, Hashable, Identifiable {
var entryType: DirectoryEntryType
var displayName: String
var simplexName: String? = nil
var groupLink: DirectoryPublicLink
var shortDescr: String? = nil
var image: String? = nil
var activeAt: Date? = nil
var createdAt: Date? = nil
// the directory drops entries with no link, but the response is untrusted input
var connectLink: String? { groupLink.connShortLink ?? groupLink.connFullLink }
// the link is stable and unique per entry, so it also de-duplicates across pages
var id: String { connectLink ?? displayName }
}
private struct DirectorySearchResponse: Decodable {
var type: String
var entries: [DirectorySearchEntry]?
var searchCursor: JSONValue?
}
struct DirectorySearchResults {
var entries: [DirectorySearchEntry]
// opaque: stored and echoed back on the next request, never inspected
var cursor: JSONValue?
}
func directorySearchRequestJSON(_ text: String, _ cursor: JSONValue?) -> String {
var req: [String: JSONValue] = ["type": .string("search"), "searchText": .string(text)]
if let cursor { req["searchCursor"] = cursor }
return encodeJSON(JSONValue.object(req))
}
// The response is a tagged object: searchResults or error. Anything else is a failure rather
// than something to parse leniently - it comes from outside the app.
func parseDirectorySearchResponse(_ resp: JSONValue) -> DirectorySearchResults? {
guard let r: DirectorySearchResponse = decodeJSONValue(resp), r.type == "searchResults" else {
return nil
}
return DirectorySearchResults(entries: r.entries ?? [], cursor: r.searchCursor)
}
+17
View File
@@ -1032,6 +1032,23 @@ func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> Pendi
if let r { throw r.unexpected } else { return nil }
}
// Blocks until the directory replies or the timeout elapses.
func apiSearchDirectory(_ text: String, cursor: JSONValue?) async -> DirectorySearchResults? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiSearchDirectory: no current user")
return nil
}
let req = directorySearchRequestJSON(text, cursor)
let r: APIResult<ChatResponse1> = await chatApiSendCmd(
.apiSendServiceRequest(userId: userId, target: DIRECTORY_SERVICE_LINK, timeoutSec: DIRECTORY_SEARCH_TIMEOUT_SEC, requestJSON: req)
)
if case let .result(.serviceResponse(_, responseData)) = r {
return parseDirectorySearchResponse(responseData)
}
logger.error("apiSearchDirectory error: \(String(describing: r))")
return nil
}
func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> ConnectionPlanResult? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectPlan: no current user")