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:
zhukang
2026-03-11 20:07:26 +08:00
parent 0a9d47027b
commit d3ca6d18dc
34 changed files with 1143 additions and 327 deletions

View File

@@ -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
}
}

View File

@@ -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)")
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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.";

View File

@@ -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 更新服务器。";

View File

@@ -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")
)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "qrcode-xiaohongshu.jpg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}