mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:34:51 +00:00
298 lines
11 KiB
Swift
298 lines
11 KiB
Swift
import Foundation
|
|
|
|
extension CatalogTreeNode {
|
|
var isRootSubscriptionGroup: Bool {
|
|
isGroup && source == .subscription && parentID == nil
|
|
}
|
|
}
|
|
|
|
/// 详情会话的展示值;包含参数明文,不进入目录工作流的非秘密投影或持久快照。
|
|
@MainActor
|
|
struct NodeDetailPresentation {
|
|
struct Property: Equatable {
|
|
let label: String
|
|
let value: String
|
|
}
|
|
|
|
enum PropertiesID: Hashable {
|
|
case subscription
|
|
case information
|
|
case connection
|
|
case pluginParameters
|
|
case connectivity
|
|
}
|
|
|
|
struct Properties: Equatable, Identifiable {
|
|
let id: PropertiesID
|
|
let title: String
|
|
let items: [Property]
|
|
}
|
|
|
|
let title: String
|
|
let subtitle: String
|
|
let activationMessages: [String]
|
|
let secondarySubtitle: String?
|
|
var properties: [Properties]
|
|
|
|
init(
|
|
node: CatalogTreeNode, server: ServerDetailPresentation?,
|
|
eligibility: ActivationEligibility?, isActiveTarget: Bool,
|
|
containsActiveTarget: Bool = false,
|
|
subscription: SubscriptionSummary? = nil,
|
|
locale: Locale = .current, timeZone: TimeZone = .current
|
|
) {
|
|
title = node.name
|
|
let isRootSubscriptionGroup = node.isRootSubscriptionGroup
|
|
secondarySubtitle =
|
|
isRootSubscriptionGroup
|
|
? String(localized: "配置由远端管理,本地不能修改。", table: "ServerDetails")
|
|
: nil
|
|
var descriptions = [
|
|
node.isGroup
|
|
? (node.isManual
|
|
? String(localized: "本地", table: "ServerDetails")
|
|
: String(localized: "订阅", table: "ServerDetails"))
|
|
: (node.isManual
|
|
? String(localized: "本地配置", table: "ServerDetails")
|
|
: String(localized: "订阅配置", table: "ServerDetails"))
|
|
]
|
|
if isActiveTarget {
|
|
descriptions.append(String(localized: "活动目标", table: "ServerDetails"))
|
|
} else if containsActiveTarget {
|
|
// 与行级空心闪电同语义:子树内含活动目标,自身不是活动目标。
|
|
descriptions.append(String(localized: "含活动目标", table: "ServerDetails"))
|
|
}
|
|
subtitle = descriptions.joined(separator: " · ")
|
|
activationMessages = Self.activationMessages(
|
|
for: node, server: server, eligibility: eligibility)
|
|
properties = []
|
|
if isRootSubscriptionGroup,
|
|
let subscriptionProperties = Self.subscriptionInformation(
|
|
summary: subscription, locale: locale, timeZone: timeZone)
|
|
{
|
|
properties.append(subscriptionProperties)
|
|
}
|
|
properties.append(Self.information(for: node, eligibility: eligibility))
|
|
if let server { properties.append(Self.connection(for: server)) }
|
|
}
|
|
|
|
private static func activationMessages(
|
|
for node: CatalogTreeNode, server: ServerDetailPresentation?,
|
|
eligibility: ActivationEligibility?
|
|
) -> [String] {
|
|
var messages: [String] = []
|
|
if node.isGroup {
|
|
if let reason = eligibility?.ineligibility {
|
|
switch reason {
|
|
case .emptyGroup:
|
|
messages.append(String(localized: "空分组,暂无子节点,不能激活。", table: "ServerDetails"))
|
|
case .noCandidates:
|
|
messages.append(String(localized: "分组中没有可激活的有效服务器。", table: "ServerDetails"))
|
|
}
|
|
}
|
|
if let count = eligibility?.skippedInvalidCount, count > 0, eligibility?.canActivate == true {
|
|
messages.append(
|
|
String(localized: "激活时将跳过 \(count) 个存在已知阻塞问题的服务器。", table: "ServerDetails"))
|
|
}
|
|
} else {
|
|
messages += node.invalidReasons.map { reason in
|
|
if case .pluginNotProvided(let program) = reason,
|
|
let warning = server?.plugin.availabilityWarning(for: program)
|
|
{
|
|
return warning
|
|
}
|
|
return AppPresentation.message(
|
|
for: ActivationFailure.invalidLeaf(node: node.id, reason: reason))
|
|
}
|
|
}
|
|
return messages
|
|
}
|
|
|
|
private static func subscriptionInformation(
|
|
summary: SubscriptionSummary?, locale: Locale, timeZone: TimeZone
|
|
) -> Properties? {
|
|
guard let summary else { return nil }
|
|
var items: [Property] = []
|
|
if let date = summary.lastSucceededAt {
|
|
items.append(
|
|
Property(
|
|
label: String(localized: "最后更新时间", table: "ServerDetails"),
|
|
value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone)))
|
|
}
|
|
items.append(
|
|
Property(
|
|
label: String(localized: "自动更新", table: "ServerDetails"),
|
|
value: summary.autoUpdate.effectiveMode.displayName))
|
|
if !summary.autoUpdate.allowsUserOverride {
|
|
items.append(
|
|
Property(
|
|
label: String(localized: "配置限制", table: "ServerDetails"),
|
|
value: String(localized: "供应商禁止覆盖自动更新设置", table: "ServerDetails")))
|
|
}
|
|
if summary.autoUpdate.effectiveMode != .off, let date = summary.autoUpdate.nextUpdateAt {
|
|
items.append(
|
|
Property(
|
|
label: String(localized: "下次更新时间", table: "ServerDetails"),
|
|
value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone)))
|
|
}
|
|
if let used = summary.information?.bytesUsed {
|
|
items.append(
|
|
Property(
|
|
label: String(localized: "已用流量", table: "ServerDetails"),
|
|
value: SubscriptionInformation.byteCountText(used, locale: locale)))
|
|
}
|
|
if let remaining = summary.information?.bytesRemaining {
|
|
items.append(
|
|
Property(
|
|
label: String(localized: "剩余流量", table: "ServerDetails"),
|
|
value: SubscriptionInformation.byteCountText(remaining, locale: locale)))
|
|
}
|
|
if let date = summary.information?.expiresAt {
|
|
items.append(
|
|
Property(
|
|
label: String(localized: "到期时间", table: "ServerDetails"),
|
|
value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone)))
|
|
}
|
|
if let date = summary.information?.trafficResetAt {
|
|
items.append(
|
|
Property(
|
|
label: String(localized: "重置时间", table: "ServerDetails"),
|
|
value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone)))
|
|
}
|
|
return Properties(
|
|
id: .subscription, title: String(localized: "订阅", table: "ServerDetails"), items: items)
|
|
}
|
|
|
|
private static func subscriptionTimestamp(
|
|
_ date: Date, locale: Locale, timeZone: TimeZone
|
|
) -> String {
|
|
date.formatted(
|
|
Date.FormatStyle(date: .abbreviated, time: .shortened, locale: locale, timeZone: timeZone))
|
|
}
|
|
|
|
private static func connection(for server: ServerDetailPresentation) -> Properties {
|
|
let program: String
|
|
switch server.plugin.selection {
|
|
case .none: program = String(localized: "无", table: "ServerDetails")
|
|
case .named(let name), .unknown(let name): program = name
|
|
}
|
|
return Properties(
|
|
id: .connection, title: String(localized: "连接信息", table: "ServerDetails"),
|
|
items: [
|
|
Property(label: String(localized: "地址", table: "ServerDetails"), value: server.address),
|
|
Property(
|
|
label: String(localized: "端口", table: "ServerDetails"), value: String(server.port)),
|
|
Property(
|
|
label: String(localized: "加密方式", table: "ServerDetails"), value: server.encryptionMethod),
|
|
Property(label: String(localized: "插件", table: "ServerDetails"), value: program),
|
|
])
|
|
}
|
|
|
|
private static func information(
|
|
for node: CatalogTreeNode, eligibility: ActivationEligibility?
|
|
) -> Properties {
|
|
var information = [Property(label: "ID", value: node.id.rawValue)]
|
|
if node.isGroup {
|
|
information += [
|
|
Property(
|
|
label: String(localized: "有效服务器", table: "ServerDetails"),
|
|
value: String(eligibility?.candidateCount ?? 0)),
|
|
Property(
|
|
label: String(localized: "无效服务器", table: "ServerDetails"),
|
|
value: String(eligibility?.skippedInvalidCount ?? 0)),
|
|
]
|
|
}
|
|
information += [
|
|
Property(
|
|
label: String(localized: "创建时间", table: "ServerDetails"),
|
|
value: Self.timestamp(node.createdAt)),
|
|
Property(
|
|
label: String(localized: "修改时间", table: "ServerDetails"),
|
|
value: Self.timestamp(node.updatedAt)),
|
|
]
|
|
return Properties(
|
|
id: .information, title: String(localized: "信息", table: "ServerDetails"), items: information)
|
|
}
|
|
|
|
mutating func appendPluginParameters(_ raw: String) {
|
|
guard !raw.isEmpty else { return }
|
|
let items: [Property]
|
|
if let parsed = PluginOptionsDraft.parse(raw) {
|
|
items = parsed.items.map { item in
|
|
Property(
|
|
label: item.key,
|
|
value: item.hasValue
|
|
? (item.value.isEmpty ? String(localized: "空值", table: "ServerDetails") : item.value)
|
|
: String(localized: "开关", table: "ServerDetails"))
|
|
}
|
|
} else {
|
|
items = [Property(label: String(localized: "原始文本", table: "ServerDetails"), value: raw)]
|
|
}
|
|
properties.append(
|
|
Properties(
|
|
id: .pluginParameters, title: String(localized: "插件参数", table: "ServerDetails"),
|
|
items: items))
|
|
}
|
|
|
|
mutating func appendPluginParameterFailure() {
|
|
properties.append(
|
|
Properties(
|
|
id: .pluginParameters, title: String(localized: "插件参数", table: "ServerDetails"),
|
|
items: [
|
|
Property(
|
|
label: String(localized: "插件参数", table: "ServerDetails"),
|
|
value: String(localized: "无法读取插件参数", table: "ServerDetails"))
|
|
]))
|
|
}
|
|
|
|
private static func timestamp(_ date: Date?) -> String {
|
|
guard let date else { return "—" }
|
|
return date.formatted(date: .numeric, time: .shortened)
|
|
}
|
|
}
|
|
|
|
extension NodeDetailPresentation {
|
|
static func connectivityHistory(
|
|
_ records: [ServerConnectivity.Record], locale: Locale, timeZone: TimeZone
|
|
) -> Properties {
|
|
Properties(
|
|
id: .connectivity, title: ConnectivityCopy.text("连通性测试"),
|
|
items: records.isEmpty
|
|
? [Property(label: ConnectivityCopy.text("状态"), value: ConnectivityCopy.text("尚未测试"))]
|
|
: records.map { record in
|
|
Property(
|
|
label: connectivityTime(record.finishedAt, locale: locale, timeZone: timeZone),
|
|
value: ConnectivityCopy.outcome(record.outcome))
|
|
})
|
|
}
|
|
|
|
static func connectivitySummary(
|
|
_ summary: ServerConnectivity.Summary, serverName: (NodeID) -> String,
|
|
locale: Locale, timeZone: TimeZone
|
|
) -> Properties {
|
|
let items: [Property]
|
|
if let id = summary.serverID, let record = summary.record {
|
|
items = [
|
|
Property(
|
|
label: ConnectivityCopy.text("响应延迟"), value: ConnectivityCopy.outcome(record.outcome)),
|
|
Property(label: ConnectivityCopy.text("服务器"), value: serverName(id)),
|
|
Property(
|
|
label: ConnectivityCopy.text("测试时间"),
|
|
value: connectivityTime(record.finishedAt, locale: locale, timeZone: timeZone)),
|
|
]
|
|
} else {
|
|
items = [
|
|
Property(
|
|
label: ConnectivityCopy.text("状态"),
|
|
value: ConnectivityCopy.text(summary.hasResults ? "无成功结果" : "尚未测试"))
|
|
]
|
|
}
|
|
return Properties(id: .connectivity, title: ConnectivityCopy.text("最快响应延迟"), items: items)
|
|
}
|
|
|
|
private static func connectivityTime(_ date: Date, locale: Locale, timeZone: TimeZone) -> String {
|
|
date.formatted(
|
|
Date.FormatStyle(date: .omitted, time: .standard, locale: locale, timeZone: timeZone))
|
|
}
|
|
}
|