diff --git a/Platform/macOS/UTMApp.swift b/Platform/macOS/UTMApp.swift index 5e22ff4cc..93c629920 100644 --- a/Platform/macOS/UTMApp.swift +++ b/Platform/macOS/UTMApp.swift @@ -32,6 +32,7 @@ struct UTMApp: App { data.showErrorAlert(message: message) } } + .modifier(UpdateAlertModifier()) } @SceneBuilder @@ -72,3 +73,12 @@ struct UTMApp: App { } } } + +private struct UpdateAlertModifier: ViewModifier { + func body(content: Content) -> some View { + ZStack { + content + UTMUpdateAlert() + } + } +} diff --git a/Platform/macOS/UTMUpdateAlert.swift b/Platform/macOS/UTMUpdateAlert.swift new file mode 100644 index 000000000..cc5a87e80 --- /dev/null +++ b/Platform/macOS/UTMUpdateAlert.swift @@ -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")) + ) + } + } +} diff --git a/Platform/macOS/UTMUpdateChecker.swift b/Platform/macOS/UTMUpdateChecker.swift new file mode 100644 index 000000000..0bc2f371b --- /dev/null +++ b/Platform/macOS/UTMUpdateChecker.swift @@ -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" + } +}