Skip to content

Commit 3b78145

Browse files
Prot10claude
andcommitted
feat: add update notification banner on app launch
- Add UpdateBanner component with yellow background that appears when update is available - UpdateManager now checks for updates on app launch and parses appcast - Banner shows available version with "Update" button and dismiss option - Added localization strings for banner text in EN/IT/ES - Version bumped to 1.1.3 (build 5) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e0f6e22 commit 3b78145

7 files changed

Lines changed: 350 additions & 21 deletions

File tree

MyMacCleaner.xcodeproj/project.pbxproj

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@
414414
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
415415
CODE_SIGN_STYLE = Manual;
416416
COMBINE_HIDPI_IMAGES = YES;
417-
CURRENT_PROJECT_VERSION = 4;
417+
CURRENT_PROJECT_VERSION = 5;
418418
DEVELOPMENT_TEAM = "";
419419
"DEVELOPMENT_TEAM[sdk=macosx*]" = 7K4SKUHU47;
420420
ENABLE_HARDENED_RUNTIME = YES;
@@ -434,7 +434,7 @@
434434
"$(inherited)",
435435
"@executable_path/../Frameworks",
436436
);
437-
MARKETING_VERSION = 1.1.2;
437+
MARKETING_VERSION = 1.1.3;
438438
PRODUCT_BUNDLE_IDENTIFIER = com.mymaccleaner.MyMacCleaner;
439439
PRODUCT_NAME = "$(TARGET_NAME)";
440440
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -460,7 +460,7 @@
460460
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
461461
CODE_SIGN_STYLE = Manual;
462462
COMBINE_HIDPI_IMAGES = YES;
463-
CURRENT_PROJECT_VERSION = 4;
463+
CURRENT_PROJECT_VERSION = 5;
464464
DEVELOPMENT_TEAM = "";
465465
"DEVELOPMENT_TEAM[sdk=macosx*]" = 7K4SKUHU47;
466466
ENABLE_HARDENED_RUNTIME = YES;
@@ -480,7 +480,7 @@
480480
"$(inherited)",
481481
"@executable_path/../Frameworks",
482482
);
483-
MARKETING_VERSION = 1.1.2;
483+
MARKETING_VERSION = 1.1.3;
484484
PRODUCT_BUNDLE_IDENTIFIER = com.mymaccleaner.MyMacCleaner;
485485
PRODUCT_NAME = "$(TARGET_NAME)";
486486
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -501,10 +501,10 @@
501501
buildSettings = {
502502
BUNDLE_LOADER = "$(TEST_HOST)";
503503
CODE_SIGN_STYLE = Automatic;
504-
CURRENT_PROJECT_VERSION = 4;
504+
CURRENT_PROJECT_VERSION = 5;
505505
GENERATE_INFOPLIST_FILE = YES;
506506
MACOSX_DEPLOYMENT_TARGET = 14.0;
507-
MARKETING_VERSION = 1.1.2;
507+
MARKETING_VERSION = 1.1.3;
508508
PRODUCT_BUNDLE_IDENTIFIER = com.mymaccleaner.MyMacCleanerTests;
509509
PRODUCT_NAME = "$(TARGET_NAME)";
510510
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -518,10 +518,10 @@
518518
buildSettings = {
519519
BUNDLE_LOADER = "$(TEST_HOST)";
520520
CODE_SIGN_STYLE = Automatic;
521-
CURRENT_PROJECT_VERSION = 4;
521+
CURRENT_PROJECT_VERSION = 5;
522522
GENERATE_INFOPLIST_FILE = YES;
523523
MACOSX_DEPLOYMENT_TARGET = 14.0;
524-
MARKETING_VERSION = 1.1.2;
524+
MARKETING_VERSION = 1.1.3;
525525
PRODUCT_BUNDLE_IDENTIFIER = com.mymaccleaner.MyMacCleanerTests;
526526
PRODUCT_NAME = "$(TARGET_NAME)";
527527
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -534,9 +534,9 @@
534534
isa = XCBuildConfiguration;
535535
buildSettings = {
536536
CODE_SIGN_STYLE = Automatic;
537-
CURRENT_PROJECT_VERSION = 4;
537+
CURRENT_PROJECT_VERSION = 5;
538538
GENERATE_INFOPLIST_FILE = YES;
539-
MARKETING_VERSION = 1.1.2;
539+
MARKETING_VERSION = 1.1.3;
540540
PRODUCT_BUNDLE_IDENTIFIER = com.mymaccleaner.MyMacCleanerUITests;
541541
PRODUCT_NAME = "$(TARGET_NAME)";
542542
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -549,9 +549,9 @@
549549
isa = XCBuildConfiguration;
550550
buildSettings = {
551551
CODE_SIGN_STYLE = Automatic;
552-
CURRENT_PROJECT_VERSION = 4;
552+
CURRENT_PROJECT_VERSION = 5;
553553
GENERATE_INFOPLIST_FILE = YES;
554-
MARKETING_VERSION = 1.1.2;
554+
MARKETING_VERSION = 1.1.3;
555555
PRODUCT_BUNDLE_IDENTIFIER = com.mymaccleaner.MyMacCleanerUITests;
556556
PRODUCT_NAME = "$(TARGET_NAME)";
557557
SWIFT_EMIT_LOC_STRINGS = NO;

MyMacCleaner/Core/Services/UpdateManager.swift

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,33 @@ import Combine
1010
import Sparkle
1111

1212
@Observable
13-
final class UpdateManager {
13+
final class UpdateManager: NSObject, SPUUpdaterDelegate {
1414
private let updaterController: SPUStandardUpdaterController
1515
private var cancellables = Set<AnyCancellable>()
1616

1717
var canCheckForUpdates: Bool = false
1818
var lastUpdateCheck: Date?
1919

20+
// Update availability state
21+
var updateAvailable: Bool = false
22+
var availableVersion: String?
23+
var updateDismissed: Bool = false
24+
2025
var automaticChecksEnabled: Bool {
2126
get { updaterController.updater.automaticallyChecksForUpdates }
2227
set { updaterController.updater.automaticallyChecksForUpdates = newValue }
2328
}
2429

25-
init() {
30+
override init() {
31+
// Initialize controller first without delegate
2632
updaterController = SPUStandardUpdaterController(
27-
startingUpdater: true,
33+
startingUpdater: false,
2834
updaterDelegate: nil,
2935
userDriverDelegate: nil
3036
)
3137

38+
super.init()
39+
3240
// Default to automatically checking for updates on first launch
3341
if !UserDefaults.standard.bool(forKey: "SUHasLaunchedBefore") {
3442
updaterController.updater.automaticallyChecksForUpdates = true
@@ -50,6 +58,14 @@ final class UpdateManager {
5058
self?.lastUpdateCheck = date
5159
}
5260
.store(in: &cancellables)
61+
62+
// Start the updater
63+
updaterController.startUpdater()
64+
65+
// Check for updates in background after a short delay
66+
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
67+
self?.checkForUpdatesQuietly()
68+
}
5369
}
5470

5571
/// Check for updates interactively (shows UI)
@@ -61,6 +77,108 @@ final class UpdateManager {
6177
func checkForUpdatesInBackground() {
6278
updaterController.updater.checkForUpdatesInBackground()
6379
}
80+
81+
/// Check for updates quietly (just to detect if update is available)
82+
func checkForUpdatesQuietly() {
83+
guard canCheckForUpdates else { return }
84+
85+
// Use background check - Sparkle will call our delegate method if update found
86+
Task {
87+
await checkAppcastForUpdates()
88+
}
89+
}
90+
91+
/// Dismiss the update banner
92+
func dismissUpdateBanner() {
93+
updateDismissed = true
94+
}
95+
96+
/// Reset dismissed state (e.g., when a new version is detected)
97+
func resetDismissedState() {
98+
updateDismissed = false
99+
}
100+
101+
// MARK: - Manual Appcast Check
102+
103+
/// Manually fetch and parse the appcast to check for updates
104+
private func checkAppcastForUpdates() async {
105+
guard let feedURL = updaterController.updater.feedURL else { return }
106+
107+
do {
108+
let (data, _) = try await URLSession.shared.data(from: feedURL)
109+
let parser = AppcastParser()
110+
if let latestVersion = parser.parseLatestVersion(from: data) {
111+
let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0"
112+
let currentShortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
113+
114+
// Compare build numbers
115+
if let latestBuild = Int(latestVersion.buildNumber),
116+
let currentBuild = Int(currentVersion),
117+
latestBuild > currentBuild {
118+
await MainActor.run {
119+
self.availableVersion = latestVersion.displayVersion
120+
self.updateAvailable = true
121+
self.updateDismissed = false
122+
}
123+
}
124+
}
125+
} catch {
126+
print("Failed to check for updates: \(error)")
127+
}
128+
}
129+
}
130+
131+
// MARK: - Appcast Parser
132+
133+
private class AppcastParser: NSObject, XMLParserDelegate {
134+
private var currentElement = ""
135+
private var latestVersion: (displayVersion: String, buildNumber: String)?
136+
private var currentDisplayVersion: String?
137+
private var currentBuildNumber: String?
138+
private var isInItem = false
139+
140+
func parseLatestVersion(from data: Data) -> (displayVersion: String, buildNumber: String)? {
141+
let parser = XMLParser(data: data)
142+
parser.delegate = self
143+
parser.parse()
144+
return latestVersion
145+
}
146+
147+
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
148+
currentElement = elementName
149+
if elementName == "item" {
150+
isInItem = true
151+
currentDisplayVersion = nil
152+
currentBuildNumber = nil
153+
}
154+
}
155+
156+
func parser(_ parser: XMLParser, foundCharacters string: String) {
157+
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
158+
guard !trimmed.isEmpty, isInItem else { return }
159+
160+
switch currentElement {
161+
case "sparkle:shortVersionString":
162+
currentDisplayVersion = (currentDisplayVersion ?? "") + trimmed
163+
case "sparkle:version":
164+
currentBuildNumber = (currentBuildNumber ?? "") + trimmed
165+
default:
166+
break
167+
}
168+
}
169+
170+
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
171+
if elementName == "item" {
172+
isInItem = false
173+
// Only take the first (latest) item
174+
if latestVersion == nil,
175+
let displayVersion = currentDisplayVersion,
176+
let buildNumber = currentBuildNumber {
177+
latestVersion = (displayVersion, buildNumber)
178+
}
179+
}
180+
currentElement = ""
181+
}
64182
}
65183

66184
#else
@@ -71,6 +189,9 @@ final class UpdateManager {
71189
var canCheckForUpdates: Bool = false
72190
var lastUpdateCheck: Date? = nil
73191
var automaticChecksEnabled: Bool = true
192+
var updateAvailable: Bool = false
193+
var availableVersion: String? = nil
194+
var updateDismissed: Bool = false
74195

75196
init() {
76197
print("⚠️ Sparkle framework not available. Add via File > Add Package Dependencies")
@@ -84,6 +205,18 @@ final class UpdateManager {
84205
func checkForUpdatesInBackground() {
85206
print("⚠️ Updates not available - Sparkle not installed")
86207
}
208+
209+
func checkForUpdatesQuietly() {
210+
print("⚠️ Updates not available - Sparkle not installed")
211+
}
212+
213+
func dismissUpdateBanner() {
214+
updateDismissed = true
215+
}
216+
217+
func resetDismissedState() {
218+
updateDismissed = false
219+
}
87220
}
88221

89222
#endif

MyMacCleaner/Features/Home/Components/PermissionPromptView.swift

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,77 @@ struct PermissionStatusView: View {
221221
}
222222
}
223223

224+
// MARK: - Update Available Banner
225+
226+
struct UpdateBanner: View {
227+
let updateManager: UpdateManager
228+
let availableVersion: String
229+
230+
@State private var isHovered = false
231+
232+
var body: some View {
233+
HStack(spacing: Theme.Spacing.md) {
234+
// Icon
235+
ZStack {
236+
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
237+
.fill(Color.yellow.opacity(0.2))
238+
.frame(width: 44, height: 44)
239+
240+
Image(systemName: "arrow.down.circle.fill")
241+
.font(Theme.Typography.size18Semibold)
242+
.foregroundStyle(.yellow)
243+
}
244+
245+
// Text
246+
VStack(alignment: .leading, spacing: Theme.Spacing.tiny) {
247+
Text(L("update.banner.title"))
248+
.font(Theme.Typography.subheadline)
249+
250+
Text(LFormat("update.banner.version %@", availableVersion))
251+
.font(Theme.Typography.caption)
252+
.foregroundStyle(.secondary)
253+
}
254+
255+
Spacer()
256+
257+
// Dismiss button
258+
Button(action: {
259+
withAnimation(Theme.Animation.spring) {
260+
updateManager.dismissUpdateBanner()
261+
}
262+
}) {
263+
Image(systemName: "xmark")
264+
.font(Theme.Typography.size12)
265+
.foregroundStyle(.secondary)
266+
.frame(width: 24, height: 24)
267+
}
268+
.buttonStyle(.plain)
269+
270+
// Update button
271+
GlassActionButton(
272+
L("update.banner.button"),
273+
icon: nil,
274+
color: .yellow
275+
) {
276+
updateManager.checkForUpdates()
277+
}
278+
}
279+
.padding(Theme.Spacing.md)
280+
.background(
281+
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
282+
.fill(Color.yellow.opacity(0.08))
283+
)
284+
.glassCard()
285+
.overlay(
286+
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
287+
.strokeBorder(Color.yellow.opacity(0.4), lineWidth: 1)
288+
)
289+
.scaleEffect(isHovered ? 1.01 : 1.0)
290+
.animation(Theme.Animation.fast, value: isHovered)
291+
.onHover { isHovered = $0 }
292+
}
293+
}
294+
224295
// MARK: - Preview
225296

226297
#Preview("Permission Prompt") {

0 commit comments

Comments
 (0)