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 <noreply@anthropic.com>
This commit is contained in:
p1neappleXpress
2026-09-28 20:59:56 +03:00
co-authored by Claude Opus 5.5
parent fc6f94ee91
commit de7bb3fd3b
+44 -12
View File
@@ -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: узел без документа
}
}