From de7bb3fd3bfb80c0121f4acca85f9285bcc13bdf Mon Sep 17 00:00:00 2001 From: p1neappleXpress Date: Mon, 28 Sep 2026 20:59:56 +0300 Subject: [PATCH] iOS: word the core's link reasons; the core names the context OpenFluxShareDecode now answers a refused link with the core's reason code (and param); ShareLinkMessages words each one, as Desktop and Android do. Export leaves the encryption context to the core (it names it by the rule every client uses) and gives documents priority 100 and the direct channel 50, like the node wizard, so a profile makes the same link here as on Desktop and Android. looksLikeLink ignores case, so a link whose letters changed case reaches the core, which says so. Co-Authored-By: Claude Opus 5.5 --- ios-app/OpenFlux/ShareLink.swift | 56 +++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/ios-app/OpenFlux/ShareLink.swift b/ios-app/OpenFlux/ShareLink.swift index b9618df..4231b6c 100644 --- a/ios-app/OpenFlux/ShareLink.swift +++ b/ios-app/OpenFlux/ShareLink.swift @@ -133,18 +133,49 @@ enum ShareImporter { } } +/// Что видит пользователь для каждой причины, по которой ядро не приняло +/// ссылку (share.Code* в ядре). Причину определяет ядро, слова — приложения. +enum ShareLinkMessages { + static func text(code: String, param: String, detail: String) -> String { + switch code { + case "not_link": return "Это не ссылка openflux://" + case "unsupported_version": return "Неподдерживаемая версия ссылки, обновите OpenFlux" + case "case_changed": return "Буквы в ссылке поменяли регистр по дороге: скопируйте её ещё раз" + case "damaged": return "Ссылка повреждена или обрезана: скопируйте её целиком ещё раз" + case "too_large": return "Ссылка слишком большая" + case "bad_payload": return "Ссылка повреждена: внутри не настройки OpenFlux" + case "bad_config": return "Не удалось собрать ссылку из профиля" + case "no_transports": return "В ссылке нет транспортов" + case "several_need_session": return "Несколько транспортов требуют режима Session" + case "session_secret": return "Для режима Session нужен ключ не короче \(param) символов" + case "short_secret": return "Ключ должен быть не короче \(param) символов" + case "unknown_codec": return "Неизвестный кодек «\(param)»" + case "not_shareable": return "\(param == "oneme" ? "MAX" : param) нельзя передать ссылкой: токен привязан к аккаунту" + case "unknown_transport": return "Неизвестный транспорт «\(param)»: возможно, нужно обновить OpenFlux" + case "direct_no_dial": return "У direct нет адреса ноды" + case "direct_needs_session": return "Direct работает только в режиме Session" + default: return detail.isEmpty ? "Не удалось обработать ссылку" : "Не удалось обработать ссылку: \(detail)" + } + } +} + enum ShareLink { private struct Result: Decodable { let error: String? + let code: String? + let param: String? let config: ShareConfig? let link: String? } + /// Any case: a link whose letters changed case on the way still goes to + /// the core, which says so. static func looksLikeLink(_ s: String) -> Bool { - s.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("openflux://") + s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().hasPrefix("openflux://") } - /// Returns the parsed config, or the error text the Go validator produced. + /// Returns the parsed config, or the user's words for why the core + /// refused it. static func decode(_ link: String) -> (config: ShareConfig?, error: String?) { let trimmed = link.trimmingCharacters(in: .whitespacesAndNewlines) guard let c = trimmed.withCString({ OpenFluxShareDecode(UnsafeMutablePointer(mutating: $0)) }) @@ -154,12 +185,15 @@ enum ShareLink { guard let data = json.data(using: .utf8), let r = try? JSONDecoder().decode(Result.self, from: data) else { return (nil, "не разобрать ответ ядра") } - if let e = r.error, !e.isEmpty { return (nil, e) } + if let e = r.error, !e.isEmpty { + return (nil, ShareLinkMessages.text(code: r.code ?? "", param: r.param ?? "", detail: e)) + } return (r.config, nil) } - /// Builds a link from a profile. Validation happens in Go, so an invalid - /// combination never leaves the app as a broken link. + /// Builds a link from a profile. The core checks it and names the + /// encryption context, so the link is the one every client makes and an + /// invalid combination never leaves the app. static func encode(profile p: Profile) -> String? { var transports: [[String: Any]] = [] var cfg: [String: Any] = ["name": p.name] @@ -176,7 +210,6 @@ enum ShareLink { transports.append(["type": "direct", "dial": dialOrURL(p)]) cfg["negotiate"] = true // стандарт требует для direct cfg["secret"] = secret - cfg["context"] = "http://#" // как pickSessionContext: узел без документа default: // ВСЕ документы профиля, а не только первый: мультиплекс у нас // выражается списком через запятую, а в стандарте — несколькими @@ -184,22 +217,21 @@ enum ShareLink { let urls = p.url.split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } - guard let first = urls.first else { return nil } + guard !urls.isEmpty else { return nil } + // Priorities as the node wizard and the other apps give them: + // documents first, the direct channel as the backup. for u in urls { - transports.append(["type": standardType(p.transportKind), "url": u]) + transports.append(["type": standardType(p.transportKind), "url": u, "priority": 100]) } - let url = first if !secret.isEmpty { cfg["secret"] = secret - cfg["context"] = url } // Прямой канал профиля едет вторым носителем — так принимающая // сторона получает и способ пройти капчу, а не только документ. if !dial.isEmpty, !directSecret.isEmpty, secret.isEmpty || secret == directSecret { - transports.append(["type": "direct", "dial": dial]) + transports.append(["type": "direct", "dial": dial, "priority": 50]) cfg["negotiate"] = true cfg["secret"] = directSecret - cfg["context"] = "http://#" // как pickSessionContext: узел без документа } }