455 lines
18 KiB
Swift
455 lines
18 KiB
Swift
import Localization
|
||
import Metrics
|
||
import SwiftUI
|
||
|
||
/// 网络测速窗口:本机宽带、国内分省三网延迟、全球节点、全球探针看目标
|
||
struct SpeedTestWindowView: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// 滚动内容会延伸到标题栏下面:顶栏垫底色压在上面,红绿灯按钮才不会被盖住
|
||
SpeedTestHeader()
|
||
.background(DS.Palette.background)
|
||
.zIndex(1)
|
||
PageScroll {
|
||
BroadbandCard()
|
||
ChinaLatencyCard()
|
||
GlobalNodeCard()
|
||
GlobalpingCard()
|
||
}
|
||
}
|
||
.background(DS.Palette.background)
|
||
.ignoresSafeArea()
|
||
}
|
||
}
|
||
|
||
// MARK: - 顶栏
|
||
|
||
private struct SpeedTestHeader: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
@Bindable var settings = model.settings
|
||
let speedTest = model.speedTest
|
||
HStack(spacing: DS.Space.s3) {
|
||
HStack(spacing: 0) {
|
||
Text(tr("网络测速"))
|
||
.dsFont(.base, weight: .semibold)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
Spacer(minLength: DS.Space.s3)
|
||
}
|
||
.frame(maxHeight: .infinity)
|
||
.background(WindowDragArea())
|
||
|
||
if speedTest.bytesUsed > 0 {
|
||
Text(tr("本次用掉 \(Format.bytes(Double(speedTest.bytesUsed)))"))
|
||
.dsFont(.xs)
|
||
.foregroundStyle(DS.Palette.textTertiary)
|
||
.lineLimit(1)
|
||
.fixedSize()
|
||
}
|
||
SegmentedControl(selection: $settings.speedTestBudget,
|
||
options: SpeedTestBudget.allCases.map { ($0, SpeedText.budget($0)) })
|
||
.frame(width: DS.Size.sidebarWidth + DS.Space.s16 * 2 + DS.Space.s4)
|
||
.help(tr("单个节点一次测速最多花的时间与流量,先到哪个停哪个"))
|
||
}
|
||
// 让出红绿灯按钮,再隔开一段,标题不贴着最右边的按钮
|
||
.padding(.leading, Self.titleLeading)
|
||
.padding(.trailing, DS.Space.s3 + DS.Space.s1)
|
||
.frame(height: DS.Size.windowHeader)
|
||
}
|
||
|
||
/// 三颗红绿灯按钮加间距约 80pt,标题从它们右边再隔一段开始
|
||
static let titleLeading = DS.Space.s16 * 5 + DS.Space.s4
|
||
}
|
||
|
||
/// 测速窗口里的一张卡片:图标标题行 + 一行说明 + 内容
|
||
private struct SpeedSection<Trailing: View, Content: View>: View {
|
||
let icon: String
|
||
let title: String
|
||
var subtitle: String?
|
||
@ViewBuilder var trailing: Trailing
|
||
@ViewBuilder var content: Content
|
||
|
||
var body: some View {
|
||
Card {
|
||
CardHeader(icon: icon, title: title) { trailing }
|
||
if let subtitle {
|
||
Text(subtitle).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||
}
|
||
content
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 开始 / 停止:跑起来之后同一个按钮变成停止
|
||
private struct RunButton: View {
|
||
let isRunning: Bool
|
||
var title: String = tr("开始")
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: DS.Space.s1) {
|
||
if isRunning {
|
||
ProgressView().controlSize(.mini)
|
||
Text(tr("停止"))
|
||
} else {
|
||
Image(systemName: "play.fill")
|
||
Text(title)
|
||
}
|
||
}
|
||
.lineLimit(1)
|
||
.fixedSize()
|
||
}
|
||
.buttonStyle(DSButtonStyle(kind: isRunning ? .secondary : .primary))
|
||
}
|
||
}
|
||
|
||
// MARK: - 本机宽带
|
||
|
||
private struct BroadbandCard: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
let speedTest = model.speedTest
|
||
let result = speedTest.broadband
|
||
SpeedSection(icon: "gauge.with.dots.needle.67percent", title: tr("本机宽带"),
|
||
subtitle: SpeedText.broadbandStatus(speedTest)) {
|
||
RunButton(isRunning: speedTest.isTestingBroadband, title: tr("测速")) { speedTest.runBroadband() }
|
||
} content: {
|
||
if speedTest.isTestingBroadband {
|
||
LiveSpeed(bitsPerSecond: speedTest.liveBitsPerSecond, stage: speedTest.broadbandStage)
|
||
}
|
||
HStack(alignment: .top, spacing: DS.Space.s3) {
|
||
SpeedStatTile(title: tr("下行"), value: SpeedText.rate(result?.download?.bitsPerSecond), tone: .primary)
|
||
SpeedStatTile(title: tr("上行"), value: SpeedText.rate(result?.upload?.bitsPerSecond), tone: .primary)
|
||
SpeedStatTile(title: tr("延迟"), value: SpeedText.milliseconds(result?.latencyMilliseconds), tone: .plain)
|
||
SpeedStatTile(title: tr("抖动"), value: SpeedText.milliseconds(result?.jitterMilliseconds), tone: .plain)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 测试进行中的实时速率
|
||
private struct LiveSpeed: View {
|
||
let bitsPerSecond: Double
|
||
let stage: BroadbandTest.Stage?
|
||
|
||
var body: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) {
|
||
Text(verbatim: SpeedText.rate(bitsPerSecond > 0 ? bitsPerSecond : nil))
|
||
.dsFont(.xl, weight: .semibold)
|
||
.foregroundStyle(DS.Palette.primary)
|
||
.monospacedDigit()
|
||
Text(SpeedText.stage(stage)).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 指标块:上面小标题,下面大数字
|
||
private struct SpeedStatTile: View {
|
||
enum Tone { case primary, plain }
|
||
let title: String
|
||
let value: String
|
||
var tone: Tone = .plain
|
||
/// 带运营商标记时,标题前面多一个品牌色小方块
|
||
var badge: ChinaCarrier?
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||
HStack(spacing: DS.Space.s1) {
|
||
if let badge { CarrierBadge(carrier: badge) }
|
||
Text(title).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||
}
|
||
Text(verbatim: value)
|
||
.dsFont(.lg, weight: .semibold)
|
||
.foregroundStyle(tone == .primary ? DS.Palette.primary : DS.Palette.textPrimary)
|
||
.monospacedDigit()
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.7)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
/// 运营商标记:品牌色小方块 + 一个字。三家的正式标志是注册商标,不随应用打包
|
||
private struct CarrierBadge: View {
|
||
let carrier: ChinaCarrier
|
||
|
||
var body: some View {
|
||
Text(verbatim: L10n.isEnglish ? carrier.markEnglish : carrier.mark)
|
||
.dsFont(.xs, weight: .semibold)
|
||
.foregroundStyle(.white)
|
||
.frame(width: DS.Space.s6, height: DS.Size.iconInline)
|
||
.background(color, in: RoundedRectangle(cornerRadius: DS.Radius.sm, style: .continuous))
|
||
.accessibilityLabel(SpeedText.carrier(carrier))
|
||
.help(SpeedText.carrier(carrier))
|
||
}
|
||
|
||
/// 三家各自的品牌色
|
||
private var color: Color {
|
||
switch carrier {
|
||
case .telecom: Color(red: 0, green: 0.36, blue: 0.67)
|
||
case .unicom: Color(red: 0.90, green: 0.07, blue: 0.09)
|
||
case .mobile: Color(red: 0.04, green: 0.63, blue: 0.86)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 国内分省三网
|
||
|
||
private struct ChinaLatencyCard: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
let speedTest = model.speedTest
|
||
SpeedSection(icon: "map", title: tr("国内分省三网延迟"),
|
||
subtitle: speedTest.isTestingChina
|
||
? tr("正在测 \(speedTest.chinaProgress)/\(ChinaNode.all.count)")
|
||
: SpeedText.checkedAt(speedTest.chinaDate)) {
|
||
RunButton(isRunning: speedTest.isTestingChina, title: tr("测延迟")) { speedTest.runChina() }
|
||
} content: {
|
||
if !speedTest.chinaLatency.isEmpty {
|
||
HStack(spacing: DS.Space.s3) {
|
||
ForEach(ChinaCarrier.allCases, id: \.self) { carrier in
|
||
let counts = speedTest.chinaReachable(carrier)
|
||
SpeedStatTile(title: tr("中位延迟"),
|
||
value: SpeedText.milliseconds(speedTest.chinaMedian(carrier)),
|
||
badge: carrier)
|
||
.help(tr("\(SpeedText.carrier(carrier)):\(counts.reachable)/\(counts.total) 个节点连得上"))
|
||
}
|
||
}
|
||
HairlineDivider()
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: DS.Space.s2) {
|
||
Text(tr("省份")).frame(width: DS.Space.s16 * 2, alignment: .leading)
|
||
ForEach(ChinaCarrier.allCases, id: \.self) { carrier in
|
||
CarrierBadge(carrier: carrier)
|
||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||
}
|
||
}
|
||
.dsFont(.xs)
|
||
.foregroundStyle(DS.Palette.textTertiary)
|
||
.padding(.vertical, DS.Space.s1)
|
||
ForEach(ChinaNode.provinces, id: \.code) { province in
|
||
HStack(spacing: DS.Space.s2) {
|
||
Text(L10n.isEnglish ? province.english : province.name)
|
||
.dsFont(.xs)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.lineLimit(1)
|
||
.frame(width: DS.Space.s16 * 2, alignment: .leading)
|
||
ForEach(ChinaCarrier.allCases, id: \.self) { carrier in
|
||
LatencyValue(speedTest.chinaLatency["\(province.code)-\(carrier.rawValue)"])
|
||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||
}
|
||
}
|
||
.padding(.vertical, DS.Space.s1 / 2)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 延迟数值:按快慢着色,连不上显示横杠
|
||
private struct LatencyValue: View {
|
||
let result: LatencyResult?
|
||
|
||
init(_ result: LatencyResult?) {
|
||
self.result = result
|
||
}
|
||
|
||
var body: some View {
|
||
Text(verbatim: SpeedText.milliseconds(result?.best))
|
||
.dsFont(.xs, weight: .medium)
|
||
.monospacedDigit()
|
||
.foregroundStyle(color)
|
||
}
|
||
|
||
private var color: Color {
|
||
guard let best = result?.best else { return DS.Palette.textTertiary }
|
||
if best < 80 { return DS.Palette.success }
|
||
if best < 200 { return DS.Palette.warning }
|
||
return DS.Palette.error
|
||
}
|
||
}
|
||
|
||
// MARK: - 全球节点
|
||
|
||
private struct GlobalNodeCard: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
let speedTest = model.speedTest
|
||
SpeedSection(icon: "globe", title: tr("全球节点"),
|
||
subtitle: speedTest.isTestingGlobal
|
||
? tr("正在测 \(speedTest.globalProgress)/\(GlobalNode.all.count)")
|
||
: SpeedText.checkedAt(speedTest.globalDate)) {
|
||
RunButton(isRunning: speedTest.isTestingGlobal, title: tr("测延迟")) { speedTest.runGlobal() }
|
||
} content: {
|
||
ForEach(GlobalRegion.allCases, id: \.self) { region in
|
||
let nodes = GlobalNode.all.filter { $0.region == region }
|
||
if !nodes.isEmpty {
|
||
Text(L10n.isEnglish ? region.nameEnglish : region.name)
|
||
.dsFont(.xs, weight: .medium)
|
||
.foregroundStyle(DS.Palette.textTertiary)
|
||
.padding(.top, DS.Space.s1)
|
||
VStack(spacing: 0) {
|
||
ForEach(nodes) { node in
|
||
GlobalNodeRow(node: node)
|
||
if node.id != nodes.last?.id { HairlineDivider() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct GlobalNodeRow: View {
|
||
@Environment(AppModel.self) private var model
|
||
let node: GlobalNode
|
||
|
||
var body: some View {
|
||
let speedTest = model.speedTest
|
||
let isDownloading = speedTest.downloadingNode == node.id
|
||
HStack(spacing: DS.Space.s2) {
|
||
FlagImage(countryCode: node.countryCode, height: DS.Space.s4)
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text(L10n.isEnglish ? node.cityEnglish : node.city)
|
||
.dsFont(.sm, weight: .medium)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
Text(verbatim: node.provider).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||
}
|
||
Spacer(minLength: DS.Space.s2)
|
||
if isDownloading {
|
||
Text(verbatim: SpeedText.rate(speedTest.liveBitsPerSecond > 0 ? speedTest.liveBitsPerSecond : nil))
|
||
.dsFont(.sm, weight: .semibold)
|
||
.foregroundStyle(DS.Palette.primary)
|
||
.monospacedDigit()
|
||
} else if let speed = speedTest.globalSpeed[node.id] {
|
||
Text(verbatim: SpeedText.rate(speed.bitsPerSecond))
|
||
.dsFont(.sm, weight: .semibold)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
.monospacedDigit()
|
||
}
|
||
LatencyValue(speedTest.globalLatency[node.id])
|
||
.frame(width: DS.Space.s16, alignment: .trailing)
|
||
Button { speedTest.download(node) } label: {
|
||
Image(systemName: isDownloading ? "stop.fill" : "arrow.down.circle")
|
||
}
|
||
.buttonStyle(DSButtonStyle(kind: .ghost))
|
||
.help(isDownloading ? tr("停止") : tr("从这个节点下载测速,上限 \(SpeedText.budget(model.settings.speedTestBudget))"))
|
||
}
|
||
.padding(.vertical, DS.Space.s1)
|
||
}
|
||
}
|
||
|
||
// MARK: - 全球探针
|
||
|
||
private struct GlobalpingCard: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
@Bindable var speedTest = model.speedTest
|
||
SpeedSection(icon: "dot.radiowaves.left.and.right", title: tr("全球探针看目标"),
|
||
subtitle: SpeedText.quota(speedTest.globalpingRun)) {
|
||
RunButton(isRunning: speedTest.isProbing, title: tr("开始探测")) { speedTest.runGlobalping() }
|
||
} content: {
|
||
HStack(spacing: DS.Space.s2) {
|
||
TextField(tr("域名,例如 example.com"), text: $speedTest.globalpingTarget)
|
||
.textFieldStyle(.roundedBorder)
|
||
.dsFont(.sm)
|
||
.onSubmit { speedTest.runGlobalping() }
|
||
SegmentedControl(selection: $speedTest.globalpingProbes, options: [(10, "10"), (20, "20"), (30, "30")])
|
||
.frame(width: DS.Space.s16 * 3)
|
||
.help(tr("探针数量,一个探针占一次额度"))
|
||
}
|
||
if let error = speedTest.globalpingError {
|
||
Text(error).dsFont(.xs).foregroundStyle(DS.Palette.error)
|
||
}
|
||
if let run = speedTest.globalpingRun {
|
||
HairlineDivider()
|
||
VStack(spacing: 0) {
|
||
ForEach(run.probes) { probe in
|
||
HStack(spacing: DS.Space.s2) {
|
||
FlagImage(countryCode: probe.countryCode, height: DS.Space.s4)
|
||
Text(verbatim: probe.city).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary)
|
||
Text(verbatim: probe.network)
|
||
.dsFont(.xs)
|
||
.foregroundStyle(DS.Palette.textTertiary)
|
||
.lineLimit(1)
|
||
Spacer(minLength: DS.Space.s2)
|
||
if let loss = probe.loss, loss > 0 {
|
||
Text(tr("丢包 \(Int(loss))%")).dsFont(.xs).foregroundStyle(DS.Palette.error)
|
||
}
|
||
Text(verbatim: SpeedText.milliseconds(probe.milliseconds))
|
||
.dsFont(.sm, weight: .medium)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
.monospacedDigit()
|
||
}
|
||
.padding(.vertical, DS.Space.s1)
|
||
if probe.id != run.probes.last?.id { HairlineDivider() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 文案
|
||
|
||
@MainActor
|
||
private enum SpeedText {
|
||
static func rate(_ bitsPerSecond: Double?) -> String {
|
||
guard let bitsPerSecond, bitsPerSecond > 0 else { return "—" }
|
||
return Format.bandwidth(bitsPerSecond)
|
||
}
|
||
|
||
static func milliseconds(_ value: Double?) -> String {
|
||
guard let value else { return "—" }
|
||
return value < 10 ? String(format: "%.1f ms", value) : "\(Int(value.rounded())) ms"
|
||
}
|
||
|
||
static func carrier(_ carrier: ChinaCarrier) -> String {
|
||
L10n.isEnglish ? carrier.nameEnglish : carrier.name
|
||
}
|
||
|
||
static func budget(_ budget: SpeedTestBudget) -> String {
|
||
let limit = budget.limit
|
||
return tr("\(Int(limit.seconds)) 秒·\(limit.bytes / 1_048_576) MB")
|
||
}
|
||
|
||
static func stage(_ stage: BroadbandTest.Stage?) -> String {
|
||
switch stage {
|
||
case .latency: tr("正在测延迟")
|
||
case .download: tr("正在测下行")
|
||
case .upload: tr("正在测上行")
|
||
case nil: ""
|
||
}
|
||
}
|
||
|
||
/// 测过之后显示走的是哪个边缘节点、什么时候测的
|
||
static func broadbandStatus(_ speedTest: SpeedTestController) -> String? {
|
||
guard let colo = speedTest.broadband?.colo else { return checkedAt(speedTest.broadbandDate) }
|
||
return tr("经 Cloudflare \(colo) 边缘节点") + (checkedAt(speedTest.broadbandDate).map { " · " + $0 } ?? "")
|
||
}
|
||
|
||
static func checkedAt(_ date: Date?) -> String? {
|
||
guard let date else { return nil }
|
||
let formatter = DateFormatter()
|
||
formatter.locale = L10n.locale
|
||
formatter.dateFormat = "HH:mm"
|
||
return tr("\(formatter.string(from: date)) 测")
|
||
}
|
||
|
||
/// 额度按调用方 IP 计算,跑过一次才知道还剩多少
|
||
static func quota(_ run: GlobalpingRun?) -> String? {
|
||
guard let run, let remaining = run.remaining, let limit = run.limit else { return nil }
|
||
return tr("本机 IP 这一小时还剩 \(remaining)/\(limit) 次探针额度")
|
||
}
|
||
}
|