feat: add in-app update checker, enhance About page and project metadata
- Add AtlasUpdateChecker with GitHub Releases API integration - Add AtlasVersionComparator for semantic version comparison - Add AboutUpdateToolbarButton with popover update UI - Enhance AboutFeatureView with social QR codes and layout refinements - Add CHANGELOG.md and CODE_OF_CONDUCT.md - Rebrand project files from Mole to Atlas for Mac - Update build script to support version/build number injection - Add installation guide to README - Add bilingual localization strings for update feature - Add unit tests for update checker and version comparator
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import AtlasDomain
|
||||
import Foundation
|
||||
|
||||
public actor AtlasUpdateChecker {
|
||||
public typealias DataLoader = @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
||||
|
||||
private let releaseURL: URL
|
||||
private let dataLoader: DataLoader
|
||||
|
||||
public init() {
|
||||
self.releaseURL = URL(string: "https://api.github.com/repos/CSZHK/CleanMyPc/releases/latest")!
|
||||
self.dataLoader = { request in
|
||||
try await URLSession.shared.data(for: request)
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
releaseURL: URL = URL(string: "https://api.github.com/repos/CSZHK/CleanMyPc/releases/latest")!,
|
||||
dataLoader: @escaping DataLoader
|
||||
) {
|
||||
self.releaseURL = releaseURL
|
||||
self.dataLoader = dataLoader
|
||||
}
|
||||
|
||||
public func checkForUpdate(currentVersion: String) async throws -> AtlasAppUpdate {
|
||||
var request = URLRequest(url: releaseURL)
|
||||
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
||||
request.timeoutInterval = 15
|
||||
|
||||
let (data, response) = try await dataLoader(request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw AtlasUpdateCheckerError.requestFailed
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200..<300:
|
||||
break
|
||||
case 404:
|
||||
throw AtlasUpdateCheckerError.noPublishedRelease
|
||||
default:
|
||||
throw AtlasUpdateCheckerError.requestFailed
|
||||
}
|
||||
|
||||
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
|
||||
|
||||
let latestVersion = release.tagName
|
||||
let isNewer = AtlasVersionComparator.isNewer(latestVersion, than: currentVersion)
|
||||
|
||||
return AtlasAppUpdate(
|
||||
currentVersion: currentVersion,
|
||||
latestVersion: latestVersion,
|
||||
releaseURL: URL(string: release.htmlURL),
|
||||
releaseNotes: release.body,
|
||||
isUpdateAvailable: isNewer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum AtlasUpdateCheckerError: LocalizedError, Sendable, Equatable {
|
||||
case requestFailed
|
||||
case noPublishedRelease
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .requestFailed:
|
||||
return AtlasL10n.string("update.error.requestFailed")
|
||||
case .noPublishedRelease:
|
||||
return AtlasL10n.string("update.notice.noPublishedRelease")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GitHubRelease: Decodable, Sendable {
|
||||
let tagName: String
|
||||
let htmlURL: String
|
||||
let body: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case tagName = "tag_name"
|
||||
case htmlURL = "html_url"
|
||||
case body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import AtlasApplication
|
||||
|
||||
final class AtlasUpdateCheckerTests: XCTestCase {
|
||||
func testCheckForUpdateReturnsAvailableRelease() async throws {
|
||||
let checker = AtlasUpdateChecker { request in
|
||||
XCTAssertEqual(
|
||||
request.url?.absoluteString,
|
||||
"https://api.github.com/repos/CSZHK/CleanMyPc/releases/latest"
|
||||
)
|
||||
let body = """
|
||||
{
|
||||
"tag_name": "V1.2.3",
|
||||
"html_url": "https://github.com/CSZHK/CleanMyPc/releases/tag/V1.2.3",
|
||||
"body": "Release notes"
|
||||
}
|
||||
"""
|
||||
return (
|
||||
Data(body.utf8),
|
||||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
|
||||
let result = try await checker.checkForUpdate(currentVersion: "1.0.0")
|
||||
|
||||
XCTAssertEqual(result.currentVersion, "1.0.0")
|
||||
XCTAssertEqual(result.latestVersion, "V1.2.3")
|
||||
XCTAssertEqual(result.releaseURL?.absoluteString, "https://github.com/CSZHK/CleanMyPc/releases/tag/V1.2.3")
|
||||
XCTAssertEqual(result.releaseNotes, "Release notes")
|
||||
XCTAssertTrue(result.isUpdateAvailable)
|
||||
}
|
||||
|
||||
func testCheckForUpdateTreatsMissingReleaseAsUnavailable() async {
|
||||
let checker = AtlasUpdateChecker { request in
|
||||
(
|
||||
Data(),
|
||||
HTTPURLResponse(url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await checker.checkForUpdate(currentVersion: "1.0.0")
|
||||
XCTFail("Expected missing releases to be reported explicitly")
|
||||
} catch let error as AtlasUpdateCheckerError {
|
||||
XCTAssertEqual(error, .noPublishedRelease)
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testCheckForUpdateMapsUnexpectedStatusToRequestFailure() async {
|
||||
let checker = AtlasUpdateChecker { request in
|
||||
(
|
||||
Data(),
|
||||
HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await checker.checkForUpdate(currentVersion: "1.0.0")
|
||||
XCTFail("Expected a request failure")
|
||||
} catch let error as AtlasUpdateCheckerError {
|
||||
XCTAssertEqual(error, .requestFailed)
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
public struct AtlasAppUpdate: Sendable, Equatable {
|
||||
public let currentVersion: String
|
||||
public let latestVersion: String
|
||||
public let releaseURL: URL?
|
||||
public let releaseNotes: String?
|
||||
public let isUpdateAvailable: Bool
|
||||
|
||||
public init(
|
||||
currentVersion: String,
|
||||
latestVersion: String,
|
||||
releaseURL: URL?,
|
||||
releaseNotes: String?,
|
||||
isUpdateAvailable: Bool
|
||||
) {
|
||||
self.currentVersion = currentVersion
|
||||
self.latestVersion = latestVersion
|
||||
self.releaseURL = releaseURL
|
||||
self.releaseNotes = releaseNotes
|
||||
self.isUpdateAvailable = isUpdateAvailable
|
||||
}
|
||||
}
|
||||
|
||||
public enum AtlasVersionComparator {
|
||||
public static func compare(_ lhs: String, _ rhs: String) -> ComparisonResult {
|
||||
let lhsParts = parse(lhs)
|
||||
let rhsParts = parse(rhs)
|
||||
|
||||
let maxCount = max(lhsParts.count, rhsParts.count)
|
||||
for index in 0..<maxCount {
|
||||
let left = index < lhsParts.count ? lhsParts[index] : 0
|
||||
let right = index < rhsParts.count ? rhsParts[index] : 0
|
||||
if left < right { return .orderedAscending }
|
||||
if left > right { return .orderedDescending }
|
||||
}
|
||||
return .orderedSame
|
||||
}
|
||||
|
||||
public static func isNewer(_ candidate: String, than current: String) -> Bool {
|
||||
compare(current, candidate) == .orderedAscending
|
||||
}
|
||||
|
||||
private static func parse(_ version: String) -> [Int] {
|
||||
var cleaned = version
|
||||
if cleaned.hasPrefix("v") || cleaned.hasPrefix("V") {
|
||||
cleaned = String(cleaned.dropFirst())
|
||||
}
|
||||
return cleaned
|
||||
.split(separator: ".")
|
||||
.compactMap { Int($0) }
|
||||
}
|
||||
}
|
||||
@@ -611,3 +611,19 @@
|
||||
"storage.screen.title" = "Storage";
|
||||
"storage.screen.subtitle" = "Reserved list-based storage views for a future scope decision.";
|
||||
"storage.largeItems.title" = "Large Items";
|
||||
|
||||
"update.version.title" = "Version";
|
||||
"update.version.current" = "Current Version";
|
||||
"update.version.build" = "Build";
|
||||
"update.check.action" = "Check for Updates";
|
||||
"update.check.checking" = "Checking…";
|
||||
"update.available.title" = "New version %@ available";
|
||||
"update.available.detail" = "Current version %@, latest version %@. Download the update from GitHub.";
|
||||
"update.available.download" = "Download";
|
||||
"update.upToDate.title" = "You're up to date";
|
||||
"update.upToDate.detail" = "Version %@ is the latest release.";
|
||||
"update.notice.title" = "Updates unavailable";
|
||||
"update.notice.noPublishedRelease" = "Atlas for Mac has not published a downloadable release yet.";
|
||||
"update.error.title" = "Update check failed";
|
||||
"update.error.detail" = "Could not reach the update server. Please try again later.";
|
||||
"update.error.requestFailed" = "Could not connect to the GitHub release server.";
|
||||
|
||||
@@ -611,3 +611,19 @@
|
||||
"storage.screen.title" = "存储";
|
||||
"storage.screen.subtitle" = "为未来版本预留的基于列表的存储视图。";
|
||||
"storage.largeItems.title" = "大文件";
|
||||
|
||||
"update.version.title" = "版本信息";
|
||||
"update.version.current" = "当前版本";
|
||||
"update.version.build" = "构建号";
|
||||
"update.check.action" = "检查更新";
|
||||
"update.check.checking" = "正在检查…";
|
||||
"update.available.title" = "发现新版本 %@";
|
||||
"update.available.detail" = "当前版本 %@,最新版本 %@。建议前往 GitHub 下载更新。";
|
||||
"update.available.download" = "前往下载";
|
||||
"update.upToDate.title" = "已是最新版本";
|
||||
"update.upToDate.detail" = "当前版本 %@ 已是最新,无需更新。";
|
||||
"update.notice.title" = "暂时无法提供更新";
|
||||
"update.notice.noPublishedRelease" = "Atlas for Mac 目前还没有发布可下载安装的版本。";
|
||||
"update.error.title" = "检查更新失败";
|
||||
"update.error.detail" = "无法连接到更新服务器,请稍后再试。";
|
||||
"update.error.requestFailed" = "无法连接到 GitHub 更新服务器。";
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import XCTest
|
||||
@testable import AtlasDomain
|
||||
|
||||
final class AtlasVersionComparatorTests: XCTestCase {
|
||||
|
||||
func testEqualVersions() {
|
||||
XCTAssertEqual(
|
||||
AtlasVersionComparator.compare("1.0.0", "1.0.0"),
|
||||
.orderedSame
|
||||
)
|
||||
}
|
||||
|
||||
func testPrefixVIsStripped() {
|
||||
XCTAssertEqual(
|
||||
AtlasVersionComparator.compare("V1.0.0", "1.0.0"),
|
||||
.orderedSame
|
||||
)
|
||||
}
|
||||
|
||||
func testLowercaseVPrefix() {
|
||||
XCTAssertEqual(
|
||||
AtlasVersionComparator.compare("v1.0.0", "1.0.0"),
|
||||
.orderedSame
|
||||
)
|
||||
}
|
||||
|
||||
func testNewerPatchVersion() {
|
||||
XCTAssertTrue(
|
||||
AtlasVersionComparator.isNewer("1.0.1", than: "1.0.0")
|
||||
)
|
||||
}
|
||||
|
||||
func testNewerMinorVersion() {
|
||||
XCTAssertTrue(
|
||||
AtlasVersionComparator.isNewer("1.1.0", than: "1.0.0")
|
||||
)
|
||||
}
|
||||
|
||||
func testNewerMajorVersion() {
|
||||
XCTAssertTrue(
|
||||
AtlasVersionComparator.isNewer("2.0.0", than: "1.99.99")
|
||||
)
|
||||
}
|
||||
|
||||
func testVPrefixNewerThanCurrent() {
|
||||
XCTAssertTrue(
|
||||
AtlasVersionComparator.isNewer("V1.30.0", than: "1.0.0")
|
||||
)
|
||||
}
|
||||
|
||||
func testVPrefixOlderThanCurrent() {
|
||||
XCTAssertFalse(
|
||||
AtlasVersionComparator.isNewer("V1.0.0", than: "1.0.1")
|
||||
)
|
||||
}
|
||||
|
||||
func testBothVPrefixed() {
|
||||
XCTAssertTrue(
|
||||
AtlasVersionComparator.isNewer("V2.0.0", than: "V1.99.99")
|
||||
)
|
||||
}
|
||||
|
||||
func testSameVersionIsNotNewer() {
|
||||
XCTAssertFalse(
|
||||
AtlasVersionComparator.isNewer("1.0.0", than: "1.0.0")
|
||||
)
|
||||
}
|
||||
|
||||
func testTwoComponentVersion() {
|
||||
XCTAssertTrue(
|
||||
AtlasVersionComparator.isNewer("1.1", than: "1.0")
|
||||
)
|
||||
}
|
||||
|
||||
func testMismatchedComponentCount() {
|
||||
XCTAssertTrue(
|
||||
AtlasVersionComparator.isNewer("1.0.1", than: "1.0")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -36,31 +36,10 @@ public struct AboutFeatureView: View {
|
||||
.font(AtlasTypography.body)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, AtlasSpacing.xs)
|
||||
|
||||
HStack(spacing: AtlasSpacing.md) {
|
||||
SocialBadge(
|
||||
assetName: "icon-wechat",
|
||||
label: AtlasL10n.string("about.social.wechat")
|
||||
)
|
||||
SocialBadge(
|
||||
assetName: "icon-xiaohongshu",
|
||||
label: AtlasL10n.string("about.social.xiaohongshu")
|
||||
)
|
||||
SocialBadge(
|
||||
assetName: "icon-x",
|
||||
label: AtlasL10n.string("about.social.x"),
|
||||
url: "https://x.com/lizikk_zhu"
|
||||
)
|
||||
SocialBadge(
|
||||
assetName: "icon-discord",
|
||||
label: AtlasL10n.string("about.social.discord"),
|
||||
url: "https://discord.gg"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SocialGrid()
|
||||
|
||||
AtlasCallout(
|
||||
title: AtlasL10n.string("about.author.quote"),
|
||||
detail: AtlasL10n.string("about.author.name"),
|
||||
@@ -107,33 +86,110 @@ public struct AboutFeatureView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SocialBadge: View {
|
||||
let assetName: String
|
||||
// MARK: - Social Grid
|
||||
|
||||
private struct SocialGrid: View {
|
||||
var body: some View {
|
||||
HStack(spacing: AtlasSpacing.md) {
|
||||
SocialCard(
|
||||
iconAsset: "icon-wechat",
|
||||
label: AtlasL10n.string("about.social.wechat"),
|
||||
qrCodeAsset: "qrcode-wechat"
|
||||
)
|
||||
SocialCard(
|
||||
iconAsset: "icon-xiaohongshu",
|
||||
label: AtlasL10n.string("about.social.xiaohongshu"),
|
||||
qrCodeAsset: "qrcode-xiaohongshu"
|
||||
)
|
||||
SocialCard(
|
||||
iconAsset: "icon-x",
|
||||
label: AtlasL10n.string("about.social.x"),
|
||||
url: "https://x.com/lizikk_zhu"
|
||||
)
|
||||
SocialCard(
|
||||
iconAsset: "icon-discord",
|
||||
label: AtlasL10n.string("about.social.discord"),
|
||||
url: "https://discord.gg/aR2kF8Xman"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SocialCard: View {
|
||||
let iconAsset: String
|
||||
let label: String
|
||||
var qrCodeAsset: String? = nil
|
||||
var url: String? = nil
|
||||
|
||||
var body: some View {
|
||||
let content = VStack(spacing: AtlasSpacing.xs) {
|
||||
Image(assetName, bundle: .module)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 28, height: 28)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
@State private var isHovering = false
|
||||
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
var body: some View {
|
||||
Group {
|
||||
if let url, let destination = URL(string: url) {
|
||||
Link(destination: destination) { cardContent }
|
||||
} else {
|
||||
cardContent
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.onHover { isHovering = $0 }
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
|
||||
if let url, let destination = URL(string: url) {
|
||||
Link(destination: destination) { content }
|
||||
} else {
|
||||
content
|
||||
private var cardContent: some View {
|
||||
VStack(spacing: AtlasSpacing.sm) {
|
||||
if let qrCodeAsset {
|
||||
Image(qrCodeAsset, bundle: .module)
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
.padding(.horizontal, AtlasSpacing.section)
|
||||
.padding(.top, AtlasSpacing.sm)
|
||||
} else {
|
||||
Spacer()
|
||||
|
||||
Image(iconAsset, bundle: .module)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 52, height: 52)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: AtlasSpacing.xxs) {
|
||||
Image(iconAsset, bundle: .module)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 12, height: 12)
|
||||
|
||||
Text(label)
|
||||
.font(AtlasTypography.captionSmall)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
if url != nil {
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, AtlasSpacing.xs)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 120)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: AtlasRadius.lg, style: .continuous)
|
||||
.fill(AtlasColor.cardRaised)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AtlasRadius.lg, style: .continuous)
|
||||
.strokeBorder(
|
||||
isHovering ? AtlasColor.borderEmphasis : AtlasColor.border,
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.scaleEffect(isHovering ? 1.02 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: isHovering)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import AtlasDesignSystem
|
||||
import AtlasDomain
|
||||
import SwiftUI
|
||||
|
||||
public struct AboutUpdateToolbarButton: View {
|
||||
private let appVersion: String
|
||||
private let appBuild: String
|
||||
private let updateResult: AtlasAppUpdate?
|
||||
private let isCheckingForUpdate: Bool
|
||||
private let updateCheckNotice: String?
|
||||
private let updateCheckError: String?
|
||||
private let onCheckForUpdate: () -> Void
|
||||
|
||||
@State private var isPopoverPresented = false
|
||||
|
||||
public init(
|
||||
appVersion: String,
|
||||
appBuild: String,
|
||||
updateResult: AtlasAppUpdate?,
|
||||
isCheckingForUpdate: Bool,
|
||||
updateCheckNotice: String?,
|
||||
updateCheckError: String?,
|
||||
onCheckForUpdate: @escaping () -> Void
|
||||
) {
|
||||
self.appVersion = appVersion
|
||||
self.appBuild = appBuild
|
||||
self.updateResult = updateResult
|
||||
self.isCheckingForUpdate = isCheckingForUpdate
|
||||
self.updateCheckNotice = updateCheckNotice
|
||||
self.updateCheckError = updateCheckError
|
||||
self.onCheckForUpdate = onCheckForUpdate
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
isPopoverPresented.toggle()
|
||||
} label: {
|
||||
HStack(spacing: AtlasSpacing.xs) {
|
||||
if isCheckingForUpdate {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
Text("v\(appVersion)")
|
||||
.font(AtlasTypography.caption)
|
||||
.foregroundStyle(AtlasColor.textPrimary)
|
||||
|
||||
if updateResult?.isUpdateAvailable == true {
|
||||
Circle()
|
||||
.fill(AtlasColor.danger)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AtlasSpacing.md)
|
||||
.padding(.vertical, AtlasSpacing.sm)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(AtlasColor.cardRaised)
|
||||
)
|
||||
.overlay(
|
||||
Capsule(style: .continuous)
|
||||
.stroke(AtlasColor.borderEmphasis, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.popover(isPresented: $isPopoverPresented, arrowEdge: .top) {
|
||||
popoverContent
|
||||
}
|
||||
}
|
||||
|
||||
private var popoverContent: some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.lg) {
|
||||
AtlasInfoCard(
|
||||
title: AtlasL10n.string("update.version.title")
|
||||
) {
|
||||
AtlasDetailRow(
|
||||
title: AtlasL10n.string("update.version.current"),
|
||||
subtitle: appVersion,
|
||||
systemImage: "tag"
|
||||
)
|
||||
|
||||
AtlasDetailRow(
|
||||
title: AtlasL10n.string("update.version.build"),
|
||||
subtitle: appBuild,
|
||||
systemImage: "hammer"
|
||||
)
|
||||
|
||||
Button {
|
||||
onCheckForUpdate()
|
||||
} label: {
|
||||
HStack(spacing: AtlasSpacing.xs) {
|
||||
if isCheckingForUpdate {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(isCheckingForUpdate
|
||||
? AtlasL10n.string("update.check.checking")
|
||||
: AtlasL10n.string("update.check.action"))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.atlasSecondary)
|
||||
.disabled(isCheckingForUpdate)
|
||||
.padding(.top, AtlasSpacing.sm)
|
||||
}
|
||||
|
||||
if let result = updateResult {
|
||||
if result.isUpdateAvailable {
|
||||
AtlasCallout(
|
||||
title: AtlasL10n.string("update.available.title", result.latestVersion),
|
||||
detail: AtlasL10n.string("update.available.detail", result.currentVersion, result.latestVersion),
|
||||
tone: .warning,
|
||||
systemImage: "arrow.down.circle"
|
||||
)
|
||||
|
||||
if let url = result.releaseURL {
|
||||
Link(destination: url) {
|
||||
Text(AtlasL10n.string("update.available.download"))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.atlasPrimary)
|
||||
}
|
||||
} else {
|
||||
AtlasCallout(
|
||||
title: AtlasL10n.string("update.upToDate.title"),
|
||||
detail: AtlasL10n.string("update.upToDate.detail", result.currentVersion),
|
||||
tone: .success,
|
||||
systemImage: "checkmark.circle"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let notice = updateCheckNotice {
|
||||
AtlasCallout(
|
||||
title: AtlasL10n.string("update.notice.title"),
|
||||
detail: notice,
|
||||
tone: .neutral,
|
||||
systemImage: "info.circle"
|
||||
)
|
||||
}
|
||||
|
||||
if let error = updateCheckError {
|
||||
AtlasCallout(
|
||||
title: AtlasL10n.string("update.error.title"),
|
||||
detail: error,
|
||||
tone: .danger,
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(AtlasSpacing.xl)
|
||||
.frame(width: 360)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "qrcode-wechat.jpg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "qrcode-xiaohongshu.jpg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Reference in New Issue
Block a user