Skip to content

Commit af5c677

Browse files
committed
add update checks
1 parent d155270 commit af5c677

3 files changed

Lines changed: 106 additions & 0 deletions

File tree

Daycal.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
E10A2062E9B84AF3B41D66DB637E49C6 /* UpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8A58BF5BFDB4946ABE7DE1E82A2CDAA /* UpdateManager.swift */; };
1011
3EF3F897261745C78EDE6D06E29EC6D1 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535844F28FC94BCE93DB74140316ADFB /* LaunchAtLoginManager.swift */; };
1112
6EA4CC8FCA0342FAB17C829DB7B261DC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A67957BACB4F30AC4F21195B22C6BC /* AppDelegate.swift */; };
1213
8147EF31B8AE4464AA928127315531EC /* AuthRedirectHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48FFB30DA4A54B69A3D40E3D37AE8707 /* AuthRedirectHandler.swift */; };
@@ -24,6 +25,7 @@
2425
/* End PBXBuildFile section */
2526

2627
/* Begin PBXFileReference section */
28+
C8A58BF5BFDB4946ABE7DE1E82A2CDAA /* UpdateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/Daycal/UpdateManager.swift; sourceTree = SOURCE_ROOT; };
2729
535844F28FC94BCE93DB74140316ADFB /* LaunchAtLoginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/Daycal/LaunchAtLoginManager.swift; sourceTree = SOURCE_ROOT; };
2830
D7A67957BACB4F30AC4F21195B22C6BC /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/Daycal/AppDelegate.swift; sourceTree = SOURCE_ROOT; };
2931
48FFB30DA4A54B69A3D40E3D37AE8707 /* AuthRedirectHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/Daycal/AuthRedirectHandler.swift; sourceTree = SOURCE_ROOT; };
@@ -59,6 +61,7 @@
5961
103243A9523C402F9B64220B1A087742 /* GoogleCalendarService.swift */,
6062
D7DE027F676A48A6942CA1D1FCDCDBA6 /* GoogleOAuthConfig.swift */,
6163
F9459B4584B44BF7A918C80A500F70CF /* MenuBarLabelView.swift */,
64+
C8A58BF5BFDB4946ABE7DE1E82A2CDAA /* UpdateManager.swift */,
6265
535844F28FC94BCE93DB74140316ADFB /* LaunchAtLoginManager.swift */,
6366
7902ABD3FBB24CD49E54389E13DCE4CC /* TokenStore.swift */,
6467
BCB8BF18CE27436EACF75AABDDB62A75 /* RedirectServer.swift */,
@@ -105,6 +108,7 @@
105108
4DC71E7165B04FB1B8524B503B4301C9 /* TokenStore.swift in Sources */,
106109
3EF3F897261745C78EDE6D06E29EC6D1 /* LaunchAtLoginManager.swift in Sources */,
107110
3053C5D78C564E61A7450FDC4064B6D5 /* RedirectServer.swift in Sources */,
111+
E10A2062E9B84AF3B41D66DB637E49C6 /* UpdateManager.swift in Sources */,
108112
); runOnlyForDeploymentPostprocessing = 0; };
109113
/* End PBXSourcesBuildPhase section */
110114

Sources/Daycal/DaycalApp.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import SwiftUI
44
struct DaycalApp: App {
55
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
66
@StateObject private var calendarStore = CalendarStore()
7+
@StateObject private var updateManager = UpdateManager()
78

89
var body: some Scene {
910
MenuBarExtra {
1011
EventsMenuView(calendarStore: calendarStore)
12+
.onAppear {
13+
updateManager.start()
14+
}
1115
} label: {
1216
MenuBarLabelView(calendarStore: calendarStore)
1317
}

Sources/Daycal/UpdateManager.swift

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import AppKit
2+
import Foundation
3+
4+
@MainActor
5+
final class UpdateManager: ObservableObject {
6+
private let owner = "jackmalcom"
7+
private let repo = "daycal"
8+
private let checkInterval: TimeInterval = 24 * 60 * 60
9+
private let lastCheckKey = "DaycalLastUpdateCheck"
10+
private var task: Task<Void, Never>?
11+
12+
func start() {
13+
guard task == nil else { return }
14+
task = Task { [weak self] in
15+
guard let self else { return }
16+
await self.checkIfNeeded(force: false)
17+
while !Task.isCancelled {
18+
try? await Task.sleep(nanoseconds: UInt64(self.checkInterval * 1_000_000_000))
19+
await self.checkIfNeeded(force: true)
20+
}
21+
}
22+
}
23+
24+
private func checkIfNeeded(force: Bool) async {
25+
let now = Date()
26+
if !force, let lastCheck = UserDefaults.standard.object(forKey: lastCheckKey) as? Date {
27+
if now.timeIntervalSince(lastCheck) < checkInterval {
28+
return
29+
}
30+
}
31+
UserDefaults.standard.set(now, forKey: lastCheckKey)
32+
33+
do {
34+
let release = try await fetchLatestRelease()
35+
guard let latestBuild = buildNumber(from: release.tagName) else { return }
36+
let currentBuild = currentBuildNumber()
37+
guard latestBuild > currentBuild else { return }
38+
39+
promptToUpdate(release: release)
40+
} catch {
41+
return
42+
}
43+
}
44+
45+
private func currentBuildNumber() -> Int {
46+
let buildString = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
47+
return Int(buildString ?? "0") ?? 0
48+
}
49+
50+
private func buildNumber(from tag: String) -> Int? {
51+
guard tag.hasPrefix("build-") else { return nil }
52+
return Int(tag.replacingOccurrences(of: "build-", with: ""))
53+
}
54+
55+
private func fetchLatestRelease() async throws -> GitHubRelease {
56+
let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases/latest")!
57+
let (data, response) = try await URLSession.shared.data(from: url)
58+
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
59+
throw URLError(.badServerResponse)
60+
}
61+
return try JSONDecoder().decode(GitHubRelease.self, from: data)
62+
}
63+
64+
private func promptToUpdate(release: GitHubRelease) {
65+
let alert = NSAlert()
66+
alert.messageText = "Update available"
67+
alert.informativeText = "A newer build of Daycal is available."
68+
alert.addButton(withTitle: "Download")
69+
alert.addButton(withTitle: "Later")
70+
71+
let response = alert.runModal()
72+
guard response == .alertFirstButtonReturn else { return }
73+
74+
if let assetURL = release.assets.first?.browserDownloadURL ?? release.htmlURL {
75+
NSWorkspace.shared.open(assetURL)
76+
}
77+
}
78+
}
79+
80+
private struct GitHubRelease: Decodable {
81+
let tagName: String
82+
let htmlURL: URL?
83+
let assets: [GitHubAsset]
84+
85+
enum CodingKeys: String, CodingKey {
86+
case tagName = "tag_name"
87+
case htmlURL = "html_url"
88+
case assets
89+
}
90+
}
91+
92+
private struct GitHubAsset: Decodable {
93+
let browserDownloadURL: URL
94+
95+
enum CodingKeys: String, CodingKey {
96+
case browserDownloadURL = "browser_download_url"
97+
}
98+
}

0 commit comments

Comments
 (0)