Skip to content

feat(DRAFT): implement update checker and alert for new versions #7036

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Platform/macOS/UTMApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ struct UTMApp: App {
data.showErrorAlert(message: message)
}
}
.modifier(UpdateAlertModifier())
}

@SceneBuilder
Expand Down Expand Up @@ -72,3 +73,12 @@ struct UTMApp: App {
}
}
}

private struct UpdateAlertModifier: ViewModifier {
func body(content: Content) -> some View {
ZStack {
content
UTMUpdateAlert()
}
}
}
43 changes: 43 additions & 0 deletions Platform/macOS/UTMUpdateAlert.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// Copyright © 2025 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//


import SwiftUI

struct UTMUpdateAlert: View {
@StateObject private var updateChecker = UTMUpdateChecker()

var body: some View {
EmptyView()
.onAppear {
Task {
await updateChecker.checkForUpdates()
}
}
.alert(isPresented: $updateChecker.isUpdateAvailable) {
Alert(
title: Text("Update Available"),
message: Text("Version \(updateChecker.latestVersion ?? "") is now available"),
primaryButton: .default(Text("Download")) {
if let url = updateChecker.updateURL {
NSWorkspace.shared.open(url)
}
},
secondaryButton: .cancel(Text("Later"))
)
}
}
}
104 changes: 104 additions & 0 deletions Platform/macOS/UTMUpdateChecker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation

class UTMUpdateChecker: ObservableObject {
@Published var isUpdateAvailable = false
@Published var latestVersion: String?
@Published var updateURL: URL?

private let currentVersion: String
private static let githubAPI = "https://api.github.com/repos/utmapp/UTM/releases/latest"

init() {
// Get current version from bundle
self.currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
}

@MainActor
func checkForUpdates() async {
do {
// Configure the API request
let configuration = URLSessionConfiguration.ephemeral
configuration.allowsCellularAccess = true
configuration.allowsExpensiveNetworkAccess = false
configuration.allowsConstrainedNetworkAccess = false
configuration.waitsForConnectivity = false
configuration.httpAdditionalHeaders = [
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28"
]

let session = URLSession(configuration: configuration)
let url = URL(string: Self.githubAPI)!
let (data, response) = try await session.data(from: url)

guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}

let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
let latestVersion = release.tagName.trimmingCharacters(in: CharacterSet(charactersIn: "v"))

self.latestVersion = latestVersion

// Compare versions
if compareVersions(currentVersion, latestVersion) {
self.isUpdateAvailable = true
// Get macOS dmg asset URL
if let dmgAsset = release.assets.first(where: { asset in
asset.name.hasSuffix(".dmg") && !asset.name.contains("unsigned")
}) {
self.updateURL = URL(string: dmgAsset.browserDownloadUrl)
}
}

} catch {
print("Failed to check for updates: \(error.localizedDescription)")
}
}

private func compareVersions(_ current: String, _ latest: String) -> Bool {
let currentComponents = current.split(separator: ".")
let latestComponents = latest.split(separator: ".")

let currentMajor = Int(currentComponents[0]) ?? 0
let currentMinor = Int(currentComponents[1]) ?? 0
let currentPatch = Int(currentComponents[2]) ?? 0

let latestMajor = Int(latestComponents[0]) ?? 0
let latestMinor = Int(latestComponents[1]) ?? 0
let latestPatch = Int(latestComponents[2]) ?? 0

if latestMajor > currentMajor {
return true
}
if latestMajor == currentMajor && latestMinor > currentMinor {
return true
}
if latestMajor == currentMajor && latestMinor == currentMinor && latestPatch > currentPatch {
return true
}
return false
}
}

// GitHub API response models
private struct GitHubRelease: Codable {
let tagName: String
let assets: [Asset]

enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case assets
}
}

private struct Asset: Codable {
let name: String
let browserDownloadUrl: String

enum CodingKeys: String, CodingKey {
case name
case browserDownloadUrl = "browser_download_url"
}
}