Skip to content

Commit 575ff41

Browse files
committed
feat: auto-update checker, 28 tools, 274 tests
- Check for updates on launch via GitHub Releases API - 'Check for Updates' in app menu + Settings About tab - Update alert with direct DMG download link - Version comparison (semver) - Orange DX icon matching app theme - 28 tools, 274 tests, all passing
1 parent 8232129 commit 575ff41

7 files changed

Lines changed: 148 additions & 3 deletions

File tree

DXTools/DXToolsApp.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ struct DXToolsApp: App {
2626
.keyboardShortcut("t", modifiers: .command)
2727
}
2828

29+
CommandGroup(after: .appInfo) {
30+
Button("Check for Updates…") {
31+
appState.checkForUpdate()
32+
}
33+
}
34+
2935
CommandGroup(after: .toolbar) {
3036
Button("Command Palette") {
3137
appState.showCommandPalette.toggle()
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Foundation
2+
3+
struct UpdateService {
4+
struct Release {
5+
let version: String
6+
let url: String
7+
let notes: String
8+
let publishedAt: String
9+
}
10+
11+
static let currentVersion: String = {
12+
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
13+
}()
14+
15+
static func checkForUpdate() async -> Release? {
16+
guard let url = URL(string: "https://api.github.com/repos/OpenStruct/dx-tools/releases/latest") else { return nil }
17+
18+
var request = URLRequest(url: url)
19+
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
20+
request.timeoutInterval = 10
21+
22+
do {
23+
let (data, response) = try await URLSession.shared.data(for: request)
24+
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
25+
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
26+
27+
guard let tagName = json["tag_name"] as? String else { return nil }
28+
let latestVersion = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
29+
let notes = json["body"] as? String ?? ""
30+
let publishedAt = json["published_at"] as? String ?? ""
31+
let htmlURL = json["html_url"] as? String ?? "https://github.com/OpenStruct/dx-tools/releases/latest"
32+
33+
// Find DMG download URL
34+
var dmgURL = htmlURL
35+
if let assets = json["assets"] as? [[String: Any]] {
36+
for asset in assets {
37+
if let name = asset["name"] as? String, name.hasSuffix(".dmg"),
38+
let downloadURL = asset["browser_download_url"] as? String {
39+
dmgURL = downloadURL
40+
break
41+
}
42+
}
43+
}
44+
45+
guard isNewer(latestVersion, than: currentVersion) else { return nil }
46+
47+
return Release(version: latestVersion, url: dmgURL, notes: notes, publishedAt: publishedAt)
48+
} catch {
49+
return nil
50+
}
51+
}
52+
53+
static func isNewer(_ remote: String, than local: String) -> Bool {
54+
let r = remote.split(separator: ".").compactMap { Int($0) }
55+
let l = local.split(separator: ".").compactMap { Int($0) }
56+
for i in 0..<max(r.count, l.count) {
57+
let rv = i < r.count ? r[i] : 0
58+
let lv = i < l.count ? l[i] : 0
59+
if rv > lv { return true }
60+
if rv < lv { return false }
61+
}
62+
return false
63+
}
64+
}

DXTools/ViewModels/AppState.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ class AppState {
7171
Tool.allCases.filter { favorites.contains($0.rawValue) }
7272
}
7373

74+
// Update
75+
var availableUpdate: UpdateService.Release?
76+
var showUpdateAlert: Bool = false
77+
7478
// Dropped file content to pass to tool
7579
var pendingDropContent: String?
7680

@@ -129,6 +133,17 @@ class AppState {
129133
}
130134
}
131135

136+
func checkForUpdate() {
137+
Task {
138+
if let release = await UpdateService.checkForUpdate() {
139+
await MainActor.run {
140+
self.availableUpdate = release
141+
self.showUpdateAlert = true
142+
}
143+
}
144+
}
145+
}
146+
132147
func checkClipboard() {
133148
guard let content = NSPasteboard.general.string(forType: .string),
134149
content != lastClipboardContent,

DXTools/Views/ContentView.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,21 @@ struct ContentView: View {
112112
.onReceive(clipboardTimer) { _ in
113113
appState.checkClipboard()
114114
}
115+
.onAppear {
116+
appState.checkForUpdate()
117+
}
118+
.alert("Update Available", isPresented: $state.showUpdateAlert) {
119+
Button("Download") {
120+
if let url = URL(string: appState.availableUpdate?.url ?? "") {
121+
NSWorkspace.shared.open(url)
122+
}
123+
}
124+
Button("Later", role: .cancel) {}
125+
} message: {
126+
if let update = appState.availableUpdate {
127+
Text("DX Tools v\(update.version) is available. You're on v\(UpdateService.currentVersion).")
128+
}
129+
}
115130
}
116131

117132
private func handleDrop(_ providers: [NSItemProvider]) {

DXTools/Views/SettingsView.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,20 @@ struct SettingsView: View {
6060
.shadow(color: .black.opacity(0.3), radius: 10, y: 4)
6161
Text("DX Tools").font(.title2).fontWeight(.bold)
6262
Text("Developer Experience Toolkit").foregroundStyle(.secondary)
63-
Text("Version 2.0.0").font(.caption).foregroundStyle(.tertiary)
63+
Text("Version \(UpdateService.currentVersion)").font(.caption).foregroundStyle(.tertiary)
6464
Divider().frame(width: 200)
6565
Text("\(Tool.allCases.count) tools · Built with SwiftUI").font(.caption).foregroundStyle(.tertiary)
66-
Text("© 2024 OpenStruct").font(.caption2).foregroundStyle(.quaternary)
66+
Text("© 2025 OpenStruct").font(.caption2).foregroundStyle(.quaternary)
67+
68+
Button("Check for Updates") {
69+
appState.checkForUpdate()
70+
if appState.availableUpdate == nil {
71+
appState.showToast("You're up to date!", icon: "checkmark.circle")
72+
}
73+
}
74+
.buttonStyle(.bordered)
75+
.controlSize(.small)
76+
6777
Spacer()
6878
}
6979
.frame(maxWidth: .infinity)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import XCTest
2+
@testable import DX_Tools
3+
4+
final class UpdateServiceTests: XCTestCase {
5+
func testNewerMajor() {
6+
XCTAssertTrue(UpdateService.isNewer("3.0.0", than: "2.0.0"))
7+
}
8+
9+
func testNewerMinor() {
10+
XCTAssertTrue(UpdateService.isNewer("2.1.0", than: "2.0.0"))
11+
}
12+
13+
func testNewerPatch() {
14+
XCTAssertTrue(UpdateService.isNewer("2.0.1", than: "2.0.0"))
15+
}
16+
17+
func testSameVersion() {
18+
XCTAssertFalse(UpdateService.isNewer("2.0.0", than: "2.0.0"))
19+
}
20+
21+
func testOlderVersion() {
22+
XCTAssertFalse(UpdateService.isNewer("1.9.0", than: "2.0.0"))
23+
}
24+
25+
func testDifferentLengths() {
26+
XCTAssertTrue(UpdateService.isNewer("2.1", than: "2.0.0"))
27+
XCTAssertFalse(UpdateService.isNewer("2.0", than: "2.0.1"))
28+
}
29+
30+
func testCurrentVersionExists() {
31+
// Should return a valid version string
32+
let v = UpdateService.currentVersion
33+
XCTAssertFalse(v.isEmpty)
34+
}
35+
}

project.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ settings:
1010
base:
1111
SWIFT_VERSION: "5.9"
1212
MACOSX_DEPLOYMENT_TARGET: "14.0"
13-
MARKETING_VERSION: "2.0.0"
13+
MARKETING_VERSION: "2.1.0"
1414
CURRENT_PROJECT_VERSION: 1
1515

1616
targets:

0 commit comments

Comments
 (0)