Files

243 lines
11 KiB
Swift

// Copyright (c) 2026 GiantAccel, LLC
// XStats modifications Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
import AppKit
import Localization
import SwiftUI
import Updates
/// 新版本信息:版本号、日期、体积与更新摘要,升级提示窗口与“关于”页共用
struct ReleaseSummary: View {
@Environment(AppModel.self) private var model
let release: UpdateRelease
let currentVersion: String
/// 升级提示窗口里限制摘要区高度,超出时滚动;关于页里完整展开
var notesHeight: CGFloat?
var showsHeader = true
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.s3) {
if showsHeader { header }
Text(tr("更新内容")).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
notes
}
.environment(\.layoutDirection, model.settings.language.resolved.isRightToLeft ? .rightToLeft : .leftToRight)
}
private var header: some View {
HStack(spacing: DS.Space.s3) {
AppGlyph(size: DS.Space.s12)
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text(verbatim: tr("XStats \(release.version) 已发布"))
.dsFont(.lg, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: tr("当前版本 \(currentVersion) · \(release.date) · \(ByteCountFormatter.string(fromByteCount: release.size, countStyle: .file))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
Spacer(minLength: 0)
}
}
@ViewBuilder
private var notes: some View {
let list = VStack(alignment: .leading, spacing: DS.Space.s2) {
ForEach(Array(release.notes(for: model.settings.language.resolved.languageCode).enumerated()), id: \.offset) { _, note in
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) {
Circle().fill(DS.Palette.primary).frame(width: DS.Space.s1 + DS.Space.s1 / 2, height: DS.Space.s1 + DS.Space.s1 / 2)
.alignmentGuide(.firstTextBaseline) { $0[VerticalAlignment.center] + DS.Space.s1 }
Text(note).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
}
// 更新摘要只使用中英文,在阿拉伯语界面中也沿用从左到右的阅读方向。
.frame(maxWidth: .infinity, alignment: .leading)
.environment(\.layoutDirection, .leftToRight)
}
if let changelog = release.changelog {
Button { NSWorkspace.shared.open(changelog) } label: {
Text(tr("查看完整更新日志")).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.primary)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if let notesHeight {
ViewThatFits(in: .vertical) {
list.padding(DS.Space.s3)
ScrollView { list.padding(DS.Space.s3).overlayScrollers() }
}
.frame(maxHeight: notesHeight)
.background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.md))
} else {
list
}
}
}
/// 下载 / 校验 / 安装的进度与失败提示
struct UpdateProgress: View {
@Environment(AppModel.self) private var model
var body: some View {
let updates = model.updates
switch updates.phase {
case .downloading(let fraction):
VStack(alignment: .leading, spacing: DS.Space.s2) {
HStack {
Text(tr("正在下载")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Spacer()
Text(verbatim: "\(Int((fraction * 100).rounded()))%").dsFont(.xs, weight: .medium).monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
ProgressTrack(fraction: fraction)
}
case .checking:
InfoBanner(icon: "arrow.triangle.2.circlepath", text: tr("正在检查更新"))
case .verifying:
InfoBanner(icon: "checkmark.shield", text: tr("正在验证更新包与开发者签名…"))
case .installing:
InfoBanner(icon: "arrow.triangle.2.circlepath", text: tr("正在安装,完成后 XStats 会自动重启。"))
case .failed(let message):
InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error) {
if updates.release != nil {
Button(tr("手动下载")) { updates.openManualDownload() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
default:
EmptyView()
}
}
}
/// 自动检查发现新版本时弹出的窗口
struct UpdatePromptView: View {
@Environment(AppModel.self) private var model
let close: () -> Void
var body: some View {
let updates = model.updates
VStack(alignment: .leading, spacing: DS.Space.s4) {
if let release = updates.release {
ReleaseSummary(release: release, currentVersion: updates.currentVersion, notesHeight: DS.Size.updateNotesHeight)
}
UpdateProgress()
HStack(spacing: DS.Space.s2) {
if case .downloading = updates.phase {
Spacer()
Button(tr("取消")) { updates.cancel() }.buttonStyle(DSButtonStyle(kind: .secondary))
} else {
Button(tr("跳过此版本")) {
updates.skipCurrentRelease()
close()
}
.buttonStyle(DSButtonStyle(kind: .ghost))
Spacer()
Button(tr("以后再说"), action: close).buttonStyle(DSButtonStyle(kind: .secondary))
Button(tr("一键安装")) { updates.install() }
.buttonStyle(DSButtonStyle(kind: .primary))
.keyboardShortcut(.defaultAction)
}
}
.disabled(updates.isBusy && !updates.isDownloading)
}
.padding(.horizontal, DS.Space.s6)
.padding(.bottom, DS.Space.s6)
.frame(width: DS.Size.updateWindowWidth)
.background(DS.Palette.background)
.appLanguageEnvironment()
}
}
/// 设置 · 关于 里的“软件更新”分组
struct UpdateSettings: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var settings = model.settings
let updates = model.updates
SettingsGroup(caption: tr("软件更新")) {
GroupRow(showsDivider: false) {
SettingRow(title: statusTitle, subtitle: statusSubtitle) {
Button(updates.phase == .checking ? tr("正在检查…") : tr("检查更新")) { updates.check(userInitiated: true) }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(updates.isBusy)
}
}
GroupRow {
SettingRow(title: tr("自动检查更新")) {
Menu {
ForEach(UpdateCheckSchedule.allCases) { schedule in
Button {
settings.updateCheckSchedule = schedule
} label: {
if settings.updateCheckSchedule == schedule {
Label(schedule.title, systemImage: "checkmark")
} else {
Text(schedule.title)
}
}
}
} label: {
HStack(spacing: DS.Space.s2) {
Text(settings.updateCheckSchedule.title)
.lineLimit(1)
Image(systemName: "chevron.down")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(DS.Palette.textSecondary)
.accessibilityHidden(true)
}
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.menuIndicator(.hidden)
.accessibilityLabel(tr("自动检查更新"))
.accessibilityValue(settings.updateCheckSchedule.title)
.fixedSize()
}
}
if let release = updates.release {
GroupRow {
VStack(alignment: .leading, spacing: DS.Space.s3) {
ReleaseSummary(release: release, currentVersion: updates.currentVersion, showsHeader: false)
UpdateProgress()
HStack {
Spacer()
if case .downloading = updates.phase {
Button(tr("取消")) { updates.cancel() }.buttonStyle(DSButtonStyle(kind: .secondary))
} else {
Button(tr("一键安装")) { updates.install() }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(updates.isBusy)
}
}
}
}
} else if case .failed = updates.phase {
GroupRow { UpdateProgress() }
}
}
}
private var statusTitle: String {
let updates = model.updates
switch updates.phase {
case .checking: return tr("正在检查更新")
case .upToDate: return tr("已是最新版本")
case .failed where updates.release == nil: return tr("检查更新失败")
default: return updates.release.map { tr("发现新版本 \($0.version)") } ?? tr("当前版本 \(updates.currentVersion)")
}
}
private var statusSubtitle: String {
let checked = model.updates.lastChecked.map { tr("上次检查:\($0.formatted(.relative(presentation: .named).locale(L10n.locale)))") } ?? tr("尚未检查")
guard let release = model.updates.release else { return checked }
let size = ByteCountFormatter.string(fromByteCount: release.size, countStyle: .file)
return tr("当前 \(model.updates.currentVersion) · 发布于 \(release.date) · \(size) · \(checked)")
}
}