Files
Luma/Sources/Shared/UI/ServerInfoView.swift
T
wt 0317277d46
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
add server info screen, connection stats, and emoji picker
Server information (Monal-style):
- Add ServerInformation model and ServerInfoView reachable from
  Settings,
  showing server software (XEP-0092), disco#info identities and
  features,
  conference (MUC) services via disco#items, and STUN/TURN via XEP-0215.
- Add a curated XEP capability list with per-XEP success/error status
  derived
  from server/account disco features and connection flags.
- Detect PEP (0163) from account disco pubsub#* features, HTTP Upload
  (0363)
  from the main domain plus its components, and add Roster Versioning
  (0237),
  Pre-Authenticated Roster Subscription (0379), and SASL SCRAM Downgrade
  Protection (0474, neutral because Martin does not expose SSDP).

Connection statistics:
- Add LumaConnectionStatsModule, an XmppStanzaFilter registered before
  stream
  management, to count sent / acknowledged / received stanzas.
- Track last login and SMACKS session timestamps, and read SASL
  mechanisms
  and Client State Indication from the raw stream features.

MAM / OMEMO handling:
- Prefer a plaintext <body> fallback when OMEMO decryption fails instead
  of
  showing "failed to decrypt".
- Keep the optimistic text of our own outgoing message when its echo
  cannot
  be decrypted back, instead of replacing it with a placeholder.

Emoji picker:
- Add EmojiCatalog and a cross-platform EmojiPickerView.
- Open the picker for reactions (replacing the static quick list) and
  from
  the composer's smiley button, inserting the chosen emoji into the
  draft.
2026-08-18 21:44:27 +07:00

280 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// "Server information" screen shown from Settings, mirroring Monal's
/// `ServerDetails`: a grouped list of headline + caption entries whose row
/// background is tinted green/orange/red according to the detected status.
struct ServerInfoView: View {
@ObservedObject var model: AppModel
@State private var isLoading = false
var body: some View {
List {
if let info = model.serverInformation {
Section {
statisticsEntries(info)
} header: {
Text("Это статистика вашего соединения.")
}
Section {
softwareEntry(info)
} header: {
Text("Это программное обеспечение, работающее на вашем сервере.")
}
Section {
connectionRows()
} header: {
Text("Параметры подключения к вашему серверу.")
}
Section {
ForEach(info.capabilities) { capability in
entry(
title: capability.title,
detail: capability.detail,
status: capability.status
)
}
} header: {
Text("Современные возможности XMPP, обнаруженные на вашем сервере после входа.")
}
Section {
mucEntries(info)
} header: {
Text("MUC-серверы, обнаруженные на вашем сервере.")
}
Section {
stunTurnEntries(info)
} header: {
Text("STUN и TURN сервисы, объявленные вашим сервером.")
}
Section {
saslEntries(info)
} header: {
Text("Методы аутентификации SASL, которые поддерживает ваш сервер.")
}
} else if isLoading {
Section {
HStack(spacing: 12) {
ProgressView()
Text("Загрузка информации о сервере…")
.foregroundStyle(.secondary)
}
}
} else {
Section {
ContentUnavailableView(
"Нет данных о сервере",
systemImage: "server.rack",
description: Text(
model.connectionStatus == .connected
? "Сервер не ответил на запрос. Попробуйте обновить."
: "Подключитесь к серверу, чтобы увидеть его возможности."
)
)
}
}
}
.navigationTitle(model.account?.domain ?? "Сервер")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await load() }
} label: {
Label("Обновить", systemImage: "arrow.clockwise")
}
.disabled(isLoading)
}
}
.task { await load() }
#if os(macOS)
.listStyle(.inset)
.frame(minWidth: 480, minHeight: 420)
#else
.listStyle(.grouped)
#endif
}
// MARK: - Sections
private func softwareEntry(_ info: ServerInformation) -> some View {
let name = info.software.name ?? "Неизвестный сервер"
let version = info.software.version ?? "неизвестная версия"
let platform = info.software.os.map { ", работает на \($0)" } ?? ""
return VStack(alignment: .leading, spacing: 4) {
Text(name)
.font(.headline)
Text("версия \(version)\(platform)")
.font(.caption)
.foregroundStyle(.secondary)
Link(
"Рекомендации для администраторов серверов",
destination: URL(
string: "https://github.com/monal-im/Monal/wiki/Considerations-for-XMPP-server-admins"
)!
)
.font(.caption)
}
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.clear)
}
private func connectionRows() -> some View {
Group {
if let account = model.account {
if let host = account.manualHost {
let port = account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222)
LabeledContent("Хост", value: "\(host):\(port)")
LabeledContent(
"Шифрование",
value: account.usesDirectTLS ? "Direct TLS" : "STARTTLS"
)
} else {
LabeledContent("Хост", value: "DNS SRV")
}
LabeledContent("Ресурс", value: account.effectiveResource)
}
}
}
@ViewBuilder
private func mucEntries(_ info: ServerInformation) -> some View {
if info.conferenceServers.isEmpty {
entry(
title: "Нет",
detail: "Этот сервер не предоставляет MUC-серверы.",
status: .error
)
} else {
ForEach(info.conferenceServers) { server in
entry(
title: "Сервер: \(server.jid)",
detail: "\(server.name ?? "<неизвестное имя>") (тип «\(server.type)», категория «\(server.category)»)",
status: server.type == "text" ? .success : .normal
)
}
}
}
@ViewBuilder
private func stunTurnEntries(_ info: ServerInformation) -> some View {
if info.externalServices.isEmpty {
entry(
title: "Нет",
detail: "Этот сервер не предоставляет STUN / TURN сервисы.",
status: .error
)
} else {
ForEach(info.externalServices) { service in
let endpoint = "\(service.host):\(service.port.map(String.init) ?? "-")"
entry(
title: service.type.uppercased(),
detail: endpoint,
status: ["stun", "turn", "stuns", "turns"].contains(service.type.lowercased())
? .success
: .error
)
}
}
}
@ViewBuilder
private func statisticsEntries(_ info: ServerInformation) -> some View {
let stats = info.connectionStats
entry(
title: "Последний вход",
detail: formatDate(stats.lastLogin),
status: .normal
)
entry(
title: "Сессия Stream Management установлена",
detail: formatDate(stats.smacksSessionEstablished),
status: .normal
)
entry(
title: "Станз отправлено / не подтверждено / получено в этой сессии",
detail: "\(stats.sent) / \(stats.unacknowledged) / \(stats.received)",
status: .normal
)
}
@ViewBuilder
private func saslEntries(_ info: ServerInformation) -> some View {
if info.saslMethods.isEmpty {
entry(
title: "Нет",
detail: "Сервер не объявил методы SASL-аутентификации.",
status: .error
)
} else {
ForEach(info.saslMethods, id: \.self) { method in
entry(
title: "Метод: \(method)",
detail: saslDescription(method),
status: method == "PLAIN" ? .warning : .normal
)
}
}
}
private func saslDescription(_ method: String) -> String {
switch method {
case "PLAIN":
return "Отправляет пароль открытым текстом (шифруется только TLS), не очень безопасно."
case "EXTERNAL":
return "Использует TLS-клиентские сертификаты для аутентификации."
case let method where method.hasPrefix("SCRAM-") && method.hasSuffix("-PLUS"):
return "Salted Challenge Response Authentication Mechanism с channel-binding."
case let method where method.hasPrefix("SCRAM-"):
return "Salted Challenge Response Authentication Mechanism на указанном хэше."
default:
return "Неизвестный метод аутентификации."
}
}
private func formatDate(_ date: Date?) -> String {
guard let date else { return "<неизвестно>" }
return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short)
}
// MARK: - Entry
private func entry(
title: String,
detail: String,
status: ServerInformation.CapabilityStatus
) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(status.color)
}
private func load() async {
guard model.connectionStatus == .connected else { return }
isLoading = true
await model.refreshServerInformation()
isLoading = false
}
}
private extension ServerInformation.CapabilityStatus {
var color: Color {
switch self {
case .success: return Color.green.opacity(0.16)
case .normal: return Color.clear
case .warning: return Color.orange.opacity(0.16)
case .error: return Color.red.opacity(0.16)
}
}
}