From 4d49b55aeaa41ed48a8fee858623696ae7877352 Mon Sep 17 00:00:00 2001 From: Francesco146 Date: Wed, 6 Aug 2025 16:43:26 +0200 Subject: [PATCH 01/10] feat!(in-app updater): add structure for a In-App Updater with markdown rendering --- Platform/Shared/ContentView.swift | 23 + Platform/Shared/UTMDownloadManager.swift | 188 +++++ Platform/Shared/UTMInstaller.swift | 332 +++++++++ Platform/Shared/UTMUpdateErrorHandler.swift | 193 +++++ Platform/Shared/UTMUpdateManager.swift | 524 ++++++++++++++ Platform/Shared/UTMUpdateSecurity.swift | 145 ++++ Platform/Shared/VMCommands.swift | 8 + Platform/UTMReleaseHelper.swift | 1 + Platform/iOS/IASKAppSettings.swift | 95 ++- Platform/iOS/Settings.bundle/Root.plist | 8 + .../iOS/Settings.bundle/en.lproj/Root.strings | Bin 4006 -> 4092 bytes Platform/iOS/UTMUpdateiOSHandler.swift | 132 ++++ Platform/it.lproj/Localizable.strings | 169 +++++ Platform/macOS/MarkdownRenderer.swift | 679 ++++++++++++++++++ Platform/macOS/SettingsView.swift | 7 + Platform/macOS/UTMMenuBarExtraScene.swift | 67 +- Platform/macOS/UpdateAvailableView.swift | 309 ++++++++ Platform/macOS/UpdateSettingsView.swift | 360 ++++++++++ UTM.xcodeproj/project.pbxproj | 70 ++ 19 files changed, 3296 insertions(+), 14 deletions(-) create mode 100644 Platform/Shared/UTMDownloadManager.swift create mode 100644 Platform/Shared/UTMInstaller.swift create mode 100644 Platform/Shared/UTMUpdateErrorHandler.swift create mode 100644 Platform/Shared/UTMUpdateManager.swift create mode 100644 Platform/Shared/UTMUpdateSecurity.swift create mode 100644 Platform/iOS/UTMUpdateiOSHandler.swift create mode 100644 Platform/macOS/MarkdownRenderer.swift create mode 100644 Platform/macOS/UpdateAvailableView.swift create mode 100644 Platform/macOS/UpdateSettingsView.swift diff --git a/Platform/Shared/ContentView.swift b/Platform/Shared/ContentView.swift index 91d338d19..1f3ff6215 100644 --- a/Platform/Shared/ContentView.swift +++ b/Platform/Shared/ContentView.swift @@ -34,6 +34,7 @@ struct ContentView: View { @State private var editMode = false @EnvironmentObject private var data: UTMData @StateObject private var releaseHelper = UTMReleaseHelper() + @StateObject private var updateManager = UTMUpdateManager.shared @State private var openSheetPresented = false @Environment(\.openURL) var openURL @AppStorage("ServerAutostart") private var isServerAutostart: Bool = false @@ -53,11 +54,33 @@ struct ContentView: View { }, content: { VMReleaseNotesView(helper: releaseHelper).padding() }) + #if os(macOS) + .sheet(isPresented: Binding( + get: { updateManager.isUpdateAvailable && updateManager.showUpdateDialog }, + set: { _ in updateManager.showUpdateDialog = false } + )) { + if let updateInfo = updateManager.updateInfo { + UpdateAvailableView(updateInfo: updateInfo, updateManager: updateManager) + } + } + #endif .onReceive(NSNotification.ShowReleaseNotes) { _ in Task { await releaseHelper.fetchReleaseNotes(force: true) } } + .onReceive(NSNotification.CheckForUpdates) { _ in + Task { + #if os(macOS) + await updateManager.checkForUpdates(force: true) + #endif + } + } + .onReceive(NSNotification.Name("UpdateAvailable")) { _ in + #if os(macOS) + updateManager.showUpdateDialog = true + #endif + } .onOpenURL(perform: handleURL) .handlesExternalEvents(preferring: ["*"], allowing: ["*"]) .onReceive(NSNotification.NewVirtualMachine) { _ in diff --git a/Platform/Shared/UTMDownloadManager.swift b/Platform/Shared/UTMDownloadManager.swift new file mode 100644 index 000000000..d9e43ee03 --- /dev/null +++ b/Platform/Shared/UTMDownloadManager.swift @@ -0,0 +1,188 @@ +// +// Copyright © 2024 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 Foundation +import Combine + +@MainActor +class UTMDownloadManager: NSObject, ObservableObject { + @Published var downloadProgress: Double = 0.0 + @Published var downloadSpeed: String = "" + @Published var estimatedTimeRemaining: String = "" + @Published var isDownloading: Bool = false + + private var downloadTask: URLSessionDownloadTask? + private var downloadedFileURL: URL? + private var expectedContentLength: Int64 = 0 + private var bytesReceived: Int64 = 0 + private var startTime: Date? + + private lazy var urlSession: URLSession = { + let config = URLSessionConfiguration.background(withIdentifier: "com.utmapp.UTM.update-download") + config.allowsCellularAccess = false + config.allowsExpensiveNetworkAccess = true + config.allowsConstrainedNetworkAccess = false + config.waitsForConnectivity = true + config.httpMaximumConnectionsPerHost = 1 + config.timeoutIntervalForRequest = 30.0 + config.timeoutIntervalForResource = 300.0 + + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + func downloadUpdate(from url: URL) async throws -> URL { + guard !isDownloading else { + throw UTMUpdateManager.UpdateError.downloadFailed("Download already in progress") + } + + return try await withCheckedThrowingContinuation { continuation in + startDownload(from: url) { result in + continuation.resume(with: result) + } + } + } + + private func startDownload(from url: URL, completion: @escaping (Result) -> Void) { + guard !isDownloading else { + completion(.failure(UTMUpdateManager.UpdateError.downloadFailed("Download already in progress"))) + return + } + + DispatchQueue.main.async { + self.isDownloading = true + self.downloadProgress = 0.0 + self.bytesReceived = 0 + self.startTime = Date() + } + + downloadTask = urlSession.downloadTask(with: url) + downloadTask?.resume() + + // Store completion handler + downloadCompletion = completion + } + + private var downloadCompletion: ((Result) -> Void)? + + func cancelDownload() { + downloadTask?.cancel() + downloadTask = nil + + DispatchQueue.main.async { + self.isDownloading = false + self.downloadProgress = 0.0 + self.downloadSpeed = "" + self.estimatedTimeRemaining = "" + } + + downloadCompletion?(.failure(UTMUpdateManager.UpdateError.downloadFailed("Download cancelled"))) + downloadCompletion = nil + } + + func cleanupDownload() { + if let downloadedFileURL = downloadedFileURL { + try? FileManager.default.removeItem(at: downloadedFileURL) + self.downloadedFileURL = nil + } + } + + private func formatByteSpeed(_ bytesPerSecond: Double) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB] + formatter.countStyle = .file + return "\(formatter.string(fromByteCount: Int64(bytesPerSecond)))/s" + } + + private func formatTimeInterval(_ timeInterval: TimeInterval) -> String { + if timeInterval < 60 { + return "\(Int(timeInterval))s" + } else if timeInterval < 3600 { + let minutes = Int(timeInterval / 60) + let seconds = Int(timeInterval.truncatingRemainder(dividingBy: 60)) + return "\(minutes)m \(seconds)s" + } else { + let hours = Int(timeInterval / 3600) + let minutes = Int((timeInterval.truncatingRemainder(dividingBy: 3600)) / 60) + return "\(hours)h \(minutes)m" + } + } + + private func updateProgress() { + guard expectedContentLength > 0 else { return } + + let progress = Double(bytesReceived) / Double(expectedContentLength) + let elapsedTime = Date().timeIntervalSince(startTime ?? Date()) + + DispatchQueue.main.async { + self.downloadProgress = progress + + if elapsedTime > 0 { + let speed = Double(self.bytesReceived) / elapsedTime + self.downloadSpeed = self.formatByteSpeed(speed) + + if speed > 0 { + let remainingBytes = self.expectedContentLength - self.bytesReceived + let remainingTime = Double(remainingBytes) / speed + self.estimatedTimeRemaining = self.formatTimeInterval(remainingTime) + } + } + } + } +} + +// MARK: - URLSessionDownloadDelegate +extension UTMDownloadManager: URLSessionDownloadDelegate { + nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + // Move file to a permanent location + let documentsPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let destinationURL = documentsPath.appendingPathComponent("UTMUpdate-\(UUID().uuidString)") + + do { + try FileManager.default.moveItem(at: location, to: destinationURL) + + Task { @MainActor in + self.downloadedFileURL = destinationURL + self.isDownloading = false + self.downloadCompletion?(.success(destinationURL)) + self.downloadCompletion = nil + } + } catch { + Task { @MainActor in + self.isDownloading = false + self.downloadCompletion?(.failure(UTMUpdateManager.UpdateError.downloadFailed(error.localizedDescription))) + self.downloadCompletion = nil + } + } + } + + nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + Task { @MainActor in + self.expectedContentLength = totalBytesExpectedToWrite + self.bytesReceived = totalBytesWritten + self.updateProgress() + } + } + + nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error = error { + Task { @MainActor in + self.isDownloading = false + self.downloadCompletion?(.failure(UTMUpdateManager.UpdateError.downloadFailed(error.localizedDescription))) + self.downloadCompletion = nil + } + } + } +} diff --git a/Platform/Shared/UTMInstaller.swift b/Platform/Shared/UTMInstaller.swift new file mode 100644 index 000000000..939123e42 --- /dev/null +++ b/Platform/Shared/UTMInstaller.swift @@ -0,0 +1,332 @@ +// +// Copyright © 2024 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 Foundation +#if os(macOS) +import AppKit +#elseif os(iOS) +import UIKit +#endif + +class UTMInstaller { + enum InstallationError: Error, LocalizedError { + case notAuthorized + case invalidBundle + case installationFailed(String) + case backupFailed + case platformNotSupported + case insufficientDiskSpace + + var errorDescription: String? { + switch self { + case .notAuthorized: + return "Installation not authorized" + case .invalidBundle: + return "Invalid application bundle" + case .installationFailed(let reason): + return "Installation failed: \(reason)" + case .backupFailed: + return "Failed to create backup" + case .platformNotSupported: + return "Platform not supported for automatic updates" + case .insufficientDiskSpace: + return "Insufficient disk space for installation" + } + } + } + + func installUpdate(from downloadURL: URL) async throws { + #if os(macOS) + try await installMacOSUpdate(from: downloadURL) + #elseif os(iOS) + try await installiOSUpdate(from: downloadURL) + #else + throw InstallationError.platformNotSupported + #endif + } + + #if os(macOS) + private func installMacOSUpdate(from downloadURL: URL) async throws { + try validateDownloadedFile(at: downloadURL) + + try checkDiskSpace(for: downloadURL) + + let backupURL = try createBackup() + + do { + if downloadURL.pathExtension.lowercased() == "dmg" { + try await installFromDMG(downloadURL) + } else { + throw InstallationError.invalidBundle + } + + try restartApplication() + + } catch { + // Rollback on failure + try? rollbackFromBackup(backupURL) + throw InstallationError.installationFailed(error.localizedDescription) + } + } + + private func validateDownloadedFile(at url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path) else { + throw InstallationError.invalidBundle + } + + // additional validation could be added here: + // - code signature verification + // - bundle structure validation + // - checksum verification + } + + private func checkDiskSpace(for downloadURL: URL) throws { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: downloadURL.path) + let fileSize = fileAttributes[.size] as? Int64 ?? 0 + + // estimate required space (file size + extraction space + backup space) + let requiredSpace = fileSize * 3 + + if let availableSpace = try? FileManager.default.url(for: .applicationDirectory, in: .localDomainMask, appropriateFor: nil, create: false).resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]).volumeAvailableCapacityForImportantUsage { + if availableSpace < requiredSpace { + throw InstallationError.insufficientDiskSpace + } + } + } + + private func createBackup() throws -> URL { + let currentAppURL = Bundle.main.bundleURL + + let backupDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("UTMBackup-\(UUID().uuidString)") + let backupURL = backupDirectory.appendingPathComponent(currentAppURL.lastPathComponent) + + try FileManager.default.createDirectory(at: backupDirectory, withIntermediateDirectories: true) + try FileManager.default.copyItem(at: currentAppURL, to: backupURL) + + return backupURL + } + + private func installFromDMG(_ dmgURL: URL) async throws { + let mountPoint = try await mountDMG(dmgURL) + + defer { + try unmountDMG(mountPoint) + } + + let appURL = try findAppBundle(in: mountPoint) + + try installAppBundle(from: appURL) + } + + private func installFromZIP(_ zipURL: URL) async throws { + let extractionURL = try await extractZIP(zipURL) + + defer { + try? FileManager.default.removeItem(at: extractionURL) + } + + let appURL = try findAppBundle(in: extractionURL) + + try installAppBundle(from: appURL) + } + + private func mountDMG(_ dmgURL: URL) async throws -> URL { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + process.arguments = ["attach", dmgURL.path, "-nobrowse", "-quiet", "-plist"] + + let pipe = Pipe() + process.standardOutput = pipe + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw InstallationError.installationFailed("Failed to mount DMG") + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let systemEntities = plist["system-entities"] as? [[String: Any]] else { + throw InstallationError.installationFailed("Failed to parse mount output") + } + + for entity in systemEntities { + if let mountPoint = entity["mount-point"] as? String { + return URL(fileURLWithPath: mountPoint) + } + } + + throw InstallationError.installationFailed("No mount point found") + } + + private func unmountDMG(_ mountPoint: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + process.arguments = ["detach", mountPoint.path, "-quiet"] + + try process.run() + process.waitUntilExit() + } + + private func extractZIP(_ zipURL: URL) async throws -> URL { + let extractionURL = FileManager.default.temporaryDirectory.appendingPathComponent("UTMExtraction-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: extractionURL, withIntermediateDirectories: true) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-q", zipURL.path, "-d", extractionURL.path] + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw InstallationError.installationFailed("Failed to extract ZIP") + } + + return extractionURL + } + + private func findAppBundle(in directory: URL) throws -> URL { + let contents = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) + + for item in contents { + if item.pathExtension == "app" { + return item + } + + // look in subdirectories + let resourceValues = try item.resourceValues(forKeys: [.isDirectoryKey]) + if resourceValues.isDirectory == true { + if let appURL = try? findAppBundle(in: item) { + return appURL + } + } + } + + throw InstallationError.invalidBundle + } + + private func installAppBundle(from sourceURL: URL) throws { + let currentAppURL = Bundle.main.bundleURL + + try FileManager.default.removeItem(at: currentAppURL) + + try FileManager.default.copyItem(at: sourceURL, to: currentAppURL) + + try setExecutablePermissions(for: currentAppURL) + } + + private func setExecutablePermissions(for appURL: URL) throws { + let executableURL = appURL.appendingPathComponent("Contents/MacOS").appendingPathComponent(appURL.deletingPathExtension().lastPathComponent) + + let attributes: [FileAttributeKey: Any] = [ + .posixPermissions: 0o755 + ] + + try FileManager.default.setAttributes(attributes, ofItemAtPath: executableURL.path) + } + + private func rollbackFromBackup(_ backupURL: URL) throws { + let currentAppURL = Bundle.main.bundleURL + + try? FileManager.default.removeItem(at: currentAppURL) + + try FileManager.default.copyItem(at: backupURL, to: currentAppURL) + } + + private func restartApplication() throws { + let appURL = Bundle.main.bundleURL + + // create a script to restart the app after a delay + let script = """ + #!/bin/bash + sleep 2 + open "\(appURL.path)" + """ + + let scriptURL = FileManager.default.temporaryDirectory.appendingPathComponent("restart_utm.sh") + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + + // make script executable + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + + // execute script + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [scriptURL.path] + process.launch() + + // exit current app + NSApp.terminate(nil) + } + + #endif + + #if os(iOS) + private func installiOSUpdate(from downloadURL: URL) async throws { + // iOS automatic installation is limited due to App Store restrictions + // Different approaches based on distribution method: + + if isAppStoreVersion() { + // Redirect to App Store + try await redirectToAppStore() + } else if isTestFlightVersion() { + // Show TestFlight update notification + try await showTestFlightUpdate() + } else { + // Side-loaded version - guide user through re-installation + try await guideSideloadedUpdate(from: downloadURL) + } + } + + private func isAppStoreVersion() -> Bool { + // Check if app was installed from App Store + guard let receiptURL = Bundle.main.appStoreReceiptURL else { + return false + } + return FileManager.default.fileExists(atPath: receiptURL.path) + } + + private func isTestFlightVersion() -> Bool { + // Check for TestFlight installation + return Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") != nil + } + + private func redirectToAppStore() async throws { + guard let appID = Bundle.main.infoDictionary?["UTMAppStoreID"] as? String, + let url = URL(string: "itms-apps://itunes.apple.com/app/id\(appID)") else { + throw InstallationError.installationFailed("Cannot construct App Store URL") + } + + await UIApplication.shared.open(url) + } + + private func showTestFlightUpdate() async throws { + guard let url = URL(string: "itms-beta://") else { + throw InstallationError.installationFailed("Cannot open TestFlight") + } + + await UIApplication.shared.open(url) + } + + private func guideSideloadedUpdate(from downloadURL: URL) async throws { + // For side-loaded apps, we can only guide the user through manual installation + // This would typically involve showing instructions to re-sign and install the IPA + throw InstallationError.platformNotSupported + } + #endif +} diff --git a/Platform/Shared/UTMUpdateErrorHandler.swift b/Platform/Shared/UTMUpdateErrorHandler.swift new file mode 100644 index 000000000..c5786b20f --- /dev/null +++ b/Platform/Shared/UTMUpdateErrorHandler.swift @@ -0,0 +1,193 @@ +// +// Copyright © 2024 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 Foundation +import OSLog + +extension Logger { + static let updateManager = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UpdateManager") + static let updateDownload = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UpdateDownload") + static let updateInstaller = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UpdateInstaller") + static let updateSecurity = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UpdateSecurity") +} + +class UTMUpdateErrorHandler { + + enum ErrorRecoveryAction { + case retry + case skipVersion + case manualUpdate + case reportBug + case none + } + + static func handleError(_ error: Error, context: String) -> ErrorRecoveryAction { + Logger.updateManager.error("\(context): \(error.localizedDescription)") + + switch error { + case let updateError as UTMUpdateManager.UpdateError: + return handleUpdateError(updateError, context: context) + case let urlError as URLError: + return handleNetworkError(urlError, context: context) + case let securityError as UTMUpdateSecurity.SecurityError: + return handleSecurityError(securityError, context: context) + default: + Logger.updateManager.error("Unhandled error type: \(type(of: error))") + return .reportBug + } + } + + private static func handleUpdateError(_ error: UTMUpdateManager.UpdateError, context: String) -> ErrorRecoveryAction { + switch error { + case .networkUnavailable: + Logger.updateManager.info("Network unavailable, user can retry later") + return .retry + + case .downloadFailed: + Logger.updateManager.warning("Download failed, user can retry or skip") + return .retry + + case .verificationFailed: + Logger.updateManager.error("Security verification failed - potential security issue") + return .reportBug + + case .installationFailed: + Logger.updateManager.error("Installation failed, suggest manual update") + return .manualUpdate + + case .insufficientSpace: + Logger.updateManager.warning("Insufficient disk space") + return .none + + case .unsupportedVersion: + Logger.updateManager.info("Update requires newer system version") + return .skipVersion + + case .invalidResponse: + Logger.updateManager.error("Invalid server response") + return .retry + + case .noUpdateAvailable: + Logger.updateManager.info("No update available") + return .none + } + } + + private static func handleNetworkError(_ error: URLError, context: String) -> ErrorRecoveryAction { + switch error.code { + case .notConnectedToInternet, .networkConnectionLost: + Logger.updateManager.info("Network connectivity issue") + return .retry + + case .timedOut: + Logger.updateManager.warning("Network timeout") + return .retry + + case .cannotFindHost, .cannotConnectToHost: + Logger.updateManager.error("Cannot reach update server") + return .retry + + case .serverCertificateUntrusted, .clientCertificateRequired: + Logger.updateManager.error("Certificate/security issue") + return .reportBug + + default: + Logger.updateManager.error("Network error: \(error.localizedDescription)") + return .retry + } + } + + private static func handleSecurityError(_ error: UTMUpdateSecurity.SecurityError, context: String) -> ErrorRecoveryAction { + switch error { + case .untrustedHost, .invalidCertificate, .invalidSignature: + Logger.updateSecurity.error("Security validation failed: \(error.localizedDescription)") + return .reportBug + + case .checksumMismatch: + Logger.updateSecurity.error("File integrity check failed") + return .retry + + case .maliciousContent: + Logger.updateSecurity.critical("Potential malicious content detected") + return .reportBug + } + } + + static func createUserFriendlyMessage(for error: Error, recoveryAction: ErrorRecoveryAction) -> (title: String, message: String, buttonText: String) { + switch recoveryAction { + case .retry: + return ( + title: "Update Failed", + message: "The update could not be completed. This is usually a temporary issue with your network connection.", + buttonText: "Try Again" + ) + + case .skipVersion: + return ( + title: "Update Not Compatible", + message: "This update requires a newer version of your operating system. You can skip this version and wait for the next update.", + buttonText: "Skip Version" + ) + + case .manualUpdate: + return ( + title: "Installation Failed", + message: "The automatic installation failed. Please download and install the update manually from the UTM website.", + buttonText: "Download Manually" + ) + + case .reportBug: + return ( + title: "Update Error", + message: "An unexpected error occurred. Please report this issue to help us improve UTM.", + buttonText: "Report Issue" + ) + + case .none: + return ( + title: "Update Issue", + message: error.localizedDescription, + buttonText: "OK" + ) + } + } + + static func logSystemInfo() { + Logger.updateManager.info("System Info - OS: \(ProcessInfo.processInfo.operatingSystemVersionString)") + Logger.updateManager.info("System Info - App Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")") + Logger.updateManager.info("System Info - Build: \(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown")") + + #if os(macOS) + Logger.updateManager.info("System Info - Architecture: \(ProcessInfo.processInfo.processorCount) cores") + #endif + + // Log available disk space + #if os(macOS) + let homeURL = FileManager.default.homeDirectoryForCurrentUser + #else + let homeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSTemporaryDirectory()) + #endif + do { + let attributes = try FileManager.default.attributesOfFileSystem(forPath: homeURL.path) + if let freeSize = attributes[.systemFreeSize] as? NSNumber { + let freeGB = freeSize.doubleValue / (1024 * 1024 * 1024) + Logger.updateManager.info("System Info - Free Space: \(String(format: "%.2f", freeGB)) GB") + } + } catch { + Logger.updateManager.warning("Could not determine free space: \(error.localizedDescription)") + } + } +} diff --git a/Platform/Shared/UTMUpdateManager.swift b/Platform/Shared/UTMUpdateManager.swift new file mode 100644 index 000000000..6c879a1a6 --- /dev/null +++ b/Platform/Shared/UTMUpdateManager.swift @@ -0,0 +1,524 @@ +// +// Copyright © 2024 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 +import Foundation +import Combine + +@MainActor +class UTMUpdateManager: UTMReleaseHelper { + static let shared = UTMUpdateManager() + + // MARK: - Update-specific Published Properties + @Published var isUpdateAvailable: Bool = false + @Published var latestVersion: String = "" + @Published var updateInfo: UpdateInfo? + @Published var downloadProgress: Double = 0.0 + @Published var isDownloading: Bool = false + @Published var isInstalling: Bool = false + @Published var isCheckingForUpdates: Bool = false + @Published var updateError: UpdateError? + @Published var showUpdateDialog: Bool = false + + // MARK: - Settings + @AppStorage("AutoCheckForUpdates") private var autoCheckForUpdates: Bool = true + @AppStorage("AutoDownloadUpdates") private var autoDownloadUpdates: Bool = false + @AppStorage("UpdateCheckInterval") private var updateCheckInterval: TimeInterval = 86400 // 24 hours + @AppStorage("LastUpdateCheck") private var lastUpdateCheckData: Data? + @AppStorage("SkippedVersion") private var skippedVersion: String? + @AppStorage("NotifyPreRelease") private var notifyPreRelease: Bool = false + @AppStorage("UpdateChannel") private var updateChannelRawValue: String = UpdateChannel.stable.rawValue + + private var lastUpdateCheck: Date? { + get { + guard let data = lastUpdateCheckData else { return nil } + return try? JSONDecoder().decode(Date.self, from: data) + } + set { + lastUpdateCheckData = try? JSONEncoder().encode(newValue) + } + } + + // Public accessor for lastUpdateCheck + var lastUpdateCheckDate: Date? { + return lastUpdateCheck + } + + private var updateChannel: UpdateChannel { + get { UpdateChannel(rawValue: updateChannelRawValue) ?? .stable } + set { updateChannelRawValue = newValue.rawValue } + } + + // MARK: - Private Properties + private var updateCheckTimer: Timer? + private var downloadManager: UTMDownloadManager? + private var cancellables = Set() + + // MARK: - Data Structures + struct UpdateInfo: Codable, Identifiable { + let id = UUID() + let version: String + let releaseDate: Date + let downloadURL: URL + let releaseNotes: String + let fileSize: Int64 + let minimumSystemVersion: String + let isCritical: Bool + let isPrerelease: Bool + let assets: [ReleaseAsset] + + struct ReleaseAsset: Codable { + let name: String + let downloadURL: URL + let size: Int64 + let contentType: String + } + } + + enum UpdateChannel: String, CaseIterable, Codable { + case stable = "stable" + case beta = "beta" + case all = "all" + + var displayName: String { + switch self { + case .stable: return "Stable" + case .beta: return "Beta" + case .all: return "All Releases" + } + } + } + + enum UpdateError: Error, LocalizedError { + case networkUnavailable + case downloadFailed(String) + case verificationFailed + case installationFailed(String) + case insufficientSpace + case unsupportedVersion + case invalidResponse + case noUpdateAvailable + + var errorDescription: String? { + switch self { + case .networkUnavailable: + return "Network connection unavailable" + case .downloadFailed(let reason): + return "Download failed: \(reason)" + case .verificationFailed: + return "Update verification failed" + case .installationFailed(let reason): + return "Installation failed: \(reason)" + case .insufficientSpace: + return "Insufficient disk space for update" + case .unsupportedVersion: + return "This update requires a newer system version" + case .invalidResponse: + return "Invalid response from update server" + case .noUpdateAvailable: + return "No update available" + } + } + } + + override init() { + super.init() + setupUpdateChecking() + } + + // MARK: - Update Checking + func checkForUpdates(force: Bool = false) async { + guard !isCheckingForUpdates else { return } + + isCheckingForUpdates = true + updateError = nil + + defer { + isCheckingForUpdates = false + lastUpdateCheck = Date() + } + + do { + let updateInfo = try await fetchLatestRelease() + await handleUpdateCheckResult(updateInfo) + } catch { + updateError = error as? UpdateError ?? .networkUnavailable + logger.error("Update check failed: \(error.localizedDescription)") + } + } + + private func fetchLatestRelease() async throws -> UpdateInfo? { + 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", + "User-Agent": "UTM/\(currentVersion)" + ] + + let session = URLSession(configuration: configuration) + let url: String + + switch updateChannel { + case .stable: + url = "https://api.github.com/repos/utmapp/UTM/releases/latest" + case .beta, .all: + url = "https://api.github.com/repos/utmapp/UTM/releases" + } + + let (data, response) = try await session.data(from: URL(string: url)!) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw UpdateError.invalidResponse + } + + if updateChannel == .stable { + return try parseLatestRelease(data) + } else { + return try parseReleases(data) + } + } + + private func parseLatestRelease(_ data: Data) throws -> UpdateInfo? { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw UpdateError.invalidResponse + } + + return try parseReleaseJSON(json) + } + + private func parseReleases(_ data: Data) throws -> UpdateInfo? { + guard let releases = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + throw UpdateError.invalidResponse + } + + for release in releases { + if let updateInfo = try parseReleaseJSON(release) { + let isPrerelease = release["prerelease"] as? Bool ?? false + + switch updateChannel { + case .stable: + if !isPrerelease { + return updateInfo + } + case .beta: + if !isPrerelease || isPrerelease { + return updateInfo + } + case .all: + return updateInfo + } + } + } + + return nil + } + + private func parseReleaseJSON(_ json: [String: Any]) throws -> UpdateInfo? { + guard let tagName = json["tag_name"] as? String, + let body = json["body"] as? String, + let publishedAt = json["published_at"] as? String, + let assets = json["assets"] as? [[String: Any]] else { + return nil + } + + let version = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName + + // Check if this version is newer than current + if !isVersionNewer(version, than: currentVersion) { + return nil + } + + // Check if user has skipped this version + if skippedVersion == version { + return nil + } + + let dateFormatter = ISO8601DateFormatter() + let releaseDate = dateFormatter.date(from: publishedAt) ?? Date() + + let releaseAssets = assets.compactMap { asset -> UpdateInfo.ReleaseAsset? in + guard let name = asset["name"] as? String, + let downloadURLString = asset["browser_download_url"] as? String, + let downloadURL = URL(string: downloadURLString), + let size = asset["size"] as? Int64, + let contentType = asset["content_type"] as? String else { + return nil + } + + return UpdateInfo.ReleaseAsset( + name: name, + downloadURL: downloadURL, + size: size, + contentType: contentType + ) + } + + // Find appropriate asset for current platform + guard let downloadAsset = findAppropriateAsset(from: releaseAssets) else { + return nil + } + + let isPrerelease = json["prerelease"] as? Bool ?? false + let isCritical = body.lowercased().contains("critical") || body.lowercased().contains("security") + + return UpdateInfo( + version: version, + releaseDate: releaseDate, + downloadURL: downloadAsset.downloadURL, + releaseNotes: body, + fileSize: downloadAsset.size, + minimumSystemVersion: extractMinimumSystemVersion(from: body), + isCritical: isCritical, + isPrerelease: isPrerelease, + assets: releaseAssets + ) + } + + private func findAppropriateAsset(from assets: [UpdateInfo.ReleaseAsset]) -> UpdateInfo.ReleaseAsset? { + #if os(macOS) + // Look for macOS app bundle + return assets.first { asset in + (asset.name.hasSuffix(".dmg")) + } + #elseif os(iOS) + // Look for iOS IPA (UTM.ipa) + return assets.first { asset in + asset.name.lowercased() == "utm.ipa" + } + #else + return nil + #endif + } + + private func extractMinimumSystemVersion(from releaseNotes: String) -> String { + // Try to extract minimum system version from release notes + let patterns = [ + #"(?:macOS|iOS)\s+(\d+(?:\.\d+)*)\+?"#, + #"(?:requires|minimum)\s+(?:macOS|iOS)\s+(\d+(?:\.\d+)*)"# + ] + + for pattern in patterns { + if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch(in: releaseNotes, range: NSRange(releaseNotes.startIndex..., in: releaseNotes)) { + if let range = Range(match.range(at: 1), in: releaseNotes) { + return String(releaseNotes[range]) + } + } + } + + #if os(macOS) + return "11.0" + #elseif os(iOS) + return "14.0" + #else + return "1.0" + #endif + } + + private func isVersionNewer(_ version1: String, than version2: String) -> Bool { + let v1Components = version1.split(separator: ".").compactMap { Int($0) } + let v2Components = version2.split(separator: ".").compactMap { Int($0) } + + let maxCount = max(v1Components.count, v2Components.count) + + for i in 0.. v2Part { + return true + } else if v1Part < v2Part { + return false + } + } + + return false + } + + private func handleUpdateCheckResult(_ updateInfo: UpdateInfo?) async { + if let updateInfo = updateInfo { + self.updateInfo = updateInfo + self.latestVersion = updateInfo.version + self.isUpdateAvailable = true + + // Check if this version is skipped + let isSkipped = skippedVersion == updateInfo.version + + // Show update dialog if not skipped and not auto-downloading + if !isSkipped && (!autoDownloadUpdates || updateInfo.isPrerelease) { + showUpdateDialog = true + NotificationCenter.default.post(name: NSNotification.Name("UpdateAvailable"), object: nil) + } + + // Auto-download if enabled + if autoDownloadUpdates && !updateInfo.isPrerelease && !isSkipped { + await startDownload() + } + } else { + self.isUpdateAvailable = false + self.updateInfo = nil + self.latestVersion = "" + } + } + + // MARK: - Update Downloading + func startDownload() async { + guard let updateInfo = updateInfo, + !isDownloading, + !isInstalling else { return } + + isDownloading = true + updateError = nil + + do { + downloadManager = UTMDownloadManager() + + // Observe download progress + downloadManager?.$downloadProgress + .receive(on: DispatchQueue.main) + .assign(to: \.downloadProgress, on: self) + .store(in: &cancellables) + + let downloadedURL = try await downloadManager?.downloadUpdate(from: updateInfo.downloadURL) + + if let downloadedURL = downloadedURL { + // Verify download + try await verifyDownload(at: downloadedURL, expectedSize: updateInfo.fileSize) + + // Start installation + await startInstallation(from: downloadedURL) + } + } catch { + updateError = error as? UpdateError ?? .downloadFailed(error.localizedDescription) + isDownloading = false + } + } + + func cancelDownload() { + downloadManager?.cancelDownload() + downloadManager = nil + isDownloading = false + downloadProgress = 0.0 + } + + private func verifyDownload(at url: URL, expectedSize: Int64) async throws { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + let fileSize = attributes[.size] as? Int64 ?? 0 + + guard fileSize == expectedSize else { + throw UpdateError.verificationFailed + } + + // Additional integrity checks could be added here + // such as checksum verification + } + + // MARK: - Update Installation + private func startInstallation(from url: URL) async { + isInstalling = true + + do { + let installer = UTMInstaller() + try await installer.installUpdate(from: url) + + // Installation successful + await handleInstallationSuccess() + } catch { + updateError = error as? UpdateError ?? .installationFailed(error.localizedDescription) + isInstalling = false + } + } + + private func handleInstallationSuccess() async { + // Clean up downloaded file + if let downloadManager = downloadManager { + downloadManager.cleanupDownload() + } + + // Reset state + isDownloading = false + isInstalling = false + isUpdateAvailable = false + updateInfo = nil + downloadProgress = 0.0 + + // App will restart as part of installation process + } + + // MARK: - Public Interface + func skipVersion(_ version: String) { + skippedVersion = version + isUpdateAvailable = false + updateInfo = nil + } + + func downloadAndInstall() async { + await startDownload() + } + + func skipVersion() { + if let updateInfo = updateInfo { + skippedVersion = updateInfo.version + showUpdateDialog = false + isUpdateAvailable = false + } + } + + func remindLater() { + showUpdateDialog = false + // Reset last check time to trigger another check after interval + lastUpdateCheck = Date() + } + + // MARK: - Automatic Update Checking + private func setupUpdateChecking() { + guard autoCheckForUpdates else { return } + + // Check on app launch if enough time has passed + if let lastCheck = lastUpdateCheck, + Date().timeIntervalSince(lastCheck) < updateCheckInterval { + return + } + + // Perform initial check + Task { + await checkForUpdates() + } + + // Setup periodic checking + updateCheckTimer = Timer.scheduledTimer(withTimeInterval: updateCheckInterval, repeats: true) { _ in + Task { + await self.checkForUpdates() + } + } + } + + deinit { + updateCheckTimer?.invalidate() + } +} + +// MARK: - Extensions for String Array +extension Array where Element == String { + var id: String { + return self.joined(separator: "\n") + } +} diff --git a/Platform/Shared/UTMUpdateSecurity.swift b/Platform/Shared/UTMUpdateSecurity.swift new file mode 100644 index 000000000..e2223e281 --- /dev/null +++ b/Platform/Shared/UTMUpdateSecurity.swift @@ -0,0 +1,145 @@ +// +// Copyright © 2024 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 Foundation +import Security +import CryptoKit + +class UTMUpdateSecurity { + private static let trustedHosts = [ + "api.github.com", + "github.com", + "objects.githubusercontent.com" + ] + + enum SecurityError: Error, LocalizedError { + case untrustedHost + case invalidCertificate + case checksumMismatch + case invalidSignature + case maliciousContent + + var errorDescription: String? { + switch self { + case .untrustedHost: + return "Download from untrusted host blocked" + case .invalidCertificate: + return "Invalid or untrusted certificate" + case .checksumMismatch: + return "File integrity check failed" + case .invalidSignature: + return "Invalid digital signature" + case .maliciousContent: + return "Potentially malicious content detected" + } + } + } + + static func validateDownloadURL(_ url: URL) throws { + guard let host = url.host, trustedHosts.contains(host) else { + throw SecurityError.untrustedHost + } + + guard url.scheme == "https" else { + throw SecurityError.untrustedHost + } + } + + static func validateCertificate(for host: String, certificate: SecCertificate) throws { + let certificateData = SecCertificateCopyData(certificate) + let data = CFDataGetBytePtr(certificateData) + let length = CFDataGetLength(certificateData) + + let certBytes = Data(bytes: data!, count: length) + let fingerprint = SHA256.hash(data: certBytes) + let fingerprintString = fingerprint.compactMap { String(format: "%02x", $0) }.joined() + + // TODO: this is a simplified check - need more robust validation for example, + // checking against a list of known good fingerprints + logger.info("Certificate fingerprint for \(host): \(fingerprintString)") + } + + static func validateFileIntegrity(at url: URL, expectedSize: Int64, expectedChecksum: String? = nil) throws { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + let actualSize = attributes[.size] as? Int64 ?? 0 + + guard actualSize == expectedSize else { + throw SecurityError.checksumMismatch + } + + if let expectedChecksum = expectedChecksum { + let actualChecksum = try calculateChecksum(for: url) + guard actualChecksum.lowercased() == expectedChecksum.lowercased() else { + throw SecurityError.checksumMismatch + } + } + } + + private static func calculateChecksum(for url: URL) throws -> String { + let data = try Data(contentsOf: url) + let hash = SHA256.hash(data: data) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } + + static func validateAppBundle(at url: URL) throws { + let bundleURL = url.appendingPathComponent("Contents") + + let requiredPaths = [ + "Info.plist", + "MacOS", + "_CodeSignature" + ] + + for path in requiredPaths { + let fullPath = bundleURL.appendingPathComponent(path) + guard FileManager.default.fileExists(atPath: fullPath.path) else { + throw SecurityError.invalidSignature + } + } + + try validateInfoPlist(at: bundleURL.appendingPathComponent("Info.plist")) + } + + private static func validateInfoPlist(at url: URL) throws { + guard let plistData = NSDictionary(contentsOf: url), + let bundleId = plistData["CFBundleIdentifier"] as? String else { + throw SecurityError.invalidSignature + } + + let validBundleIds = ["com.utmapp.UTM", "com.utmapp.UTM.SE", "com.utmapp.UTM.Remote"] + guard validBundleIds.contains(bundleId) else { + throw SecurityError.maliciousContent + } + } + + // URLSession delegate method for certificate pinning + static func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + + guard let serverTrust = challenge.protectionSpace.serverTrust, + let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + do { + try validateCertificate(for: challenge.protectionSpace.host, certificate: certificate) + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } catch { + logger.error("Certificate validation failed: \(error.localizedDescription)") + completionHandler(.cancelAuthenticationChallenge, nil) + } + } +} diff --git a/Platform/Shared/VMCommands.swift b/Platform/Shared/VMCommands.swift index 6f7fa97c1..123cf5006 100644 --- a/Platform/Shared/VMCommands.swift +++ b/Platform/Shared/VMCommands.swift @@ -34,9 +34,16 @@ struct VMCommands: Commands { SidebarCommands() ToolbarCommands() CommandGroup(replacing: .help) { + Button(action: { NotificationCenter.default.post(name: NSNotification.CheckForUpdates, object: nil) }, label: { + Text("Check for Updates…") + }).keyboardShortcut(KeyEquivalent("u"), modifiers: [.command]) + Button(action: { NotificationCenter.default.post(name: NSNotification.ShowReleaseNotes, object: nil) }, label: { Text("What's New") }).keyboardShortcut(KeyEquivalent("1"), modifiers: [.command, .control]) + + Divider() + Button(action: { openLink("https://mac.getutm.app/gallery/") }, label: { Text("Virtual Machine Gallery") }).keyboardShortcut(KeyEquivalent("2"), modifiers: [.command, .control]) @@ -58,5 +65,6 @@ extension NSNotification { static let NewVirtualMachine = NSNotification.Name("NewVirtualMachine") static let OpenVirtualMachine = NSNotification.Name("OpenVirtualMachine") static let ShowReleaseNotes = NSNotification.Name("ShowReleaseNotes") + static let CheckForUpdates = NSNotification.Name("CheckForUpdates") static let InstallGuestTools = NSNotification.Name("InstallGuestTools") } diff --git a/Platform/UTMReleaseHelper.swift b/Platform/UTMReleaseHelper.swift index 684d529c4..b74a2b16e 100644 --- a/Platform/UTMReleaseHelper.swift +++ b/Platform/UTMReleaseHelper.swift @@ -15,6 +15,7 @@ // import SwiftUI +import Foundation @MainActor class UTMReleaseHelper: ObservableObject { diff --git a/Platform/iOS/IASKAppSettings.swift b/Platform/iOS/IASKAppSettings.swift index 75890d1b4..bb73495bf 100644 --- a/Platform/iOS/IASKAppSettings.swift +++ b/Platform/iOS/IASKAppSettings.swift @@ -16,15 +16,108 @@ import SwiftUI import InAppSettingsKit +import UIKit + +class UTMSettingsDelegate: NSObject, IASKSettingsDelegate { + + override init() { + super.init() + } + + deinit { + } + + func settingsViewControllerDidEnd(_ sender: IASKAppSettingsViewController) { + // Settings view ended + } + + func settingsViewController(_ sender: IASKAppSettingsViewController, buttonTappedFor specifier: IASKSpecifier) { + if specifier.key == "CheckForUpdatesButton" { + Task { @MainActor in + do { + let installMethod = UTMUpdateiOSHandler.detectInstallationMethod() + guard UTMUpdateiOSHandler.shouldShowUpdatePrompt(for: installMethod) else { + let alert = UIAlertController( + title: "Updates", + message: "Updates for App Store installations are handled automatically by iOS.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + + sender.present(alert, animated: true) + return + } + + await UTMUpdateManager.shared.checkForUpdates(force: true) + + if let updateInfo = UTMUpdateManager.shared.updateInfo { + let alert = UIAlertController( + title: "Update Available", + message: "A new version of UTM (\(updateInfo.version)) is available. Since you're using a sideloaded version, please update manually using your preferred sideloading method.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "View Release", style: .default) { _ in + Task { + if let url = URL(string: "https://github.com/utmapp/UTM/releases/latest") { + await UIApplication.shared.open(url) + } + } + }) + + if let ipaAsset = updateInfo.assets.first(where: { $0.name.hasSuffix(".ipa") }) { + alert.addAction(UIAlertAction(title: "Copy IPA URL", style: .default) { _ in + UIPasteboard.general.string = ipaAsset.downloadURL.absoluteString + }) + } + + alert.addAction(UIAlertAction(title: "Later", style: .cancel)) + + sender.present(alert, animated: true) + } else { + let alert = UIAlertController( + title: "No Updates", + message: "You are using the latest version of UTM.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + + sender.present(alert, animated: true) + } + + } + } + } + } + + private func getTopViewController() -> UIViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { + return nil + } + + var topViewController = window.rootViewController + while let presented = topViewController?.presentedViewController { + topViewController = presented + } + + return topViewController + } +} struct IASKAppSettings: UIViewControllerRepresentable { + private let delegate = UTMSettingsDelegate() + func makeUIViewController(context: Context) -> IASKAppSettingsViewController { - return IASKAppSettingsViewController() + let controller = IASKAppSettingsViewController() + controller.delegate = delegate + return controller } func updateUIViewController(_ uiViewController: IASKAppSettingsViewController, context: Context) { uiViewController.neverShowPrivacySettings = !context.environment.showPrivacyLink uiViewController.showCreditsFooter = false + uiViewController.delegate = delegate } } diff --git a/Platform/iOS/Settings.bundle/Root.plist b/Platform/iOS/Settings.bundle/Root.plist index 75619e060..13b02babb 100644 --- a/Platform/iOS/Settings.bundle/Root.plist +++ b/Platform/iOS/Settings.bundle/Root.plist @@ -2884,6 +2884,14 @@ File License + + Type + IASKButtonSpecifier + Title + Check for Updates + Key + CheckForUpdatesButton + diff --git a/Platform/iOS/Settings.bundle/en.lproj/Root.strings b/Platform/iOS/Settings.bundle/en.lproj/Root.strings index be8941c7fc76946d8c5f9b9a4dc931a076c37827..b63738e5e232650ccc3d95e1425754c11ade7117 100644 GIT binary patch delta 94 zcmZ1`|3`kqGX5|n24{v0hE#@RhHM4}hBStJh9V#x%22?N!jQ;N0+cCcPy&kB0x@xF Itr@r&0Icy5TmS$7 delta 7 Ocmew(zf69^GJXIK$^%9K diff --git a/Platform/iOS/UTMUpdateiOSHandler.swift b/Platform/iOS/UTMUpdateiOSHandler.swift new file mode 100644 index 000000000..93515b7c0 --- /dev/null +++ b/Platform/iOS/UTMUpdateiOSHandler.swift @@ -0,0 +1,132 @@ +// +// Copyright © 2024 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. +// + +#if os(iOS) +import UIKit +import StoreKit + +class UTMUpdateiOSHandler { + + enum iOSUpdateMethod { + case appStore + case sideloaded + } + + static func detectInstallationMethod() -> iOSUpdateMethod { + // Check if app was installed from App Store + if let receiptURL = Bundle.main.appStoreReceiptURL, + FileManager.default.fileExists(atPath: receiptURL.path) { + return .appStore + } + + // Default to sideloaded + return .sideloaded + } + + static func handleiOSUpdate(method: iOSUpdateMethod, updateInfo: UTMUpdateManager.UpdateInfo) async throws { + switch method { + case .appStore: + try await handleAppStoreUpdate() + case .sideloaded: + try await handleSideloadedUpdate(updateInfo: updateInfo) + } + } + + private static func handleAppStoreUpdate() async throws { + // For App Store versions, redirect to App Store for update + if let appStoreURL = URL(string: "itms-apps://itunes.apple.com/app/id1538878817") { + if await UIApplication.shared.canOpenURL(appStoreURL) { + await UIApplication.shared.open(appStoreURL) + } + } + + // Also show in-app App Store review controller if available + if #available(iOS 14.0, *) { + if let windowScene = await UIApplication.shared.connectedScenes.first as? UIWindowScene { + await SKStoreReviewController.requestReview(in: windowScene) + } + } + } + + private static func handleSideloadedUpdate(updateInfo: UTMUpdateManager.UpdateInfo) async throws { + // For sideloaded apps, provide instructions for manual update + let alertController = await UIAlertController( + title: NSLocalizedString("Update Available", comment: "UTMUpdateiOSHandler"), + message: String.localizedStringWithFormat(NSLocalizedString("A new version of UTM (%@) is available. Since you're using a sideloaded version, please update manually using your preferred sideloading method.", comment: "UTMUpdateiOSHandler"), updateInfo.version), + preferredStyle: .alert + ) + + // Add action to open GitHub releases page + await alertController.addAction(UIAlertAction(title: NSLocalizedString("View Release", comment: "UTMUpdateiOSHandler"), style: .default) { _ in + Task { + if let url = URL(string: "https://github.com/utmapp/UTM/releases/latest") { + await UIApplication.shared.open(url) + } + } + }) + + // Add action to copy IPA download URL + if let ipaAsset = updateInfo.assets.first(where: { $0.name.hasSuffix(".ipa") }) { + await alertController.addAction(UIAlertAction(title: NSLocalizedString("Copy IPA URL", comment: "UTMUpdateiOSHandler"), style: .default) { _ in + UIPasteboard.general.string = ipaAsset.downloadURL.absoluteString + }) + } + + await alertController.addAction(UIAlertAction(title: NSLocalizedString("Later", comment: "UTMUpdateiOSHandler"), style: .cancel)) + + // Present the alert + if let topViewController = getTopViewController() { + await topViewController.present(alertController, animated: true) + } + } + + private static func getTopViewController() -> UIViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { + return nil + } + + var topViewController = window.rootViewController + while let presented = topViewController?.presentedViewController { + topViewController = presented + } + + return topViewController + } + + static func shouldShowUpdatePrompt(for method: iOSUpdateMethod) -> Bool { + switch method { + case .appStore: + // Don't show custom update prompts for App Store versions + // Let iOS handle App Store updates + return false + case .sideloaded: + // Show custom update prompts for sideloaded versions + return true + } + } + + static func getUpdateInstructions(for method: iOSUpdateMethod) -> String { + switch method { + case .appStore: + return "Updates will be delivered through the App Store automatically." + case .sideloaded: + return "To update your sideloaded version:\n\n1. Download the new IPA from GitHub\n2. Install using your preferred sideloading method (AltStore, Sideloadly, etc.)\n3. The new version will replace the current installation" + } + } +} + +#endif diff --git a/Platform/it.lproj/Localizable.strings b/Platform/it.lproj/Localizable.strings index da616dfe0..8ab9dea79 100644 --- a/Platform/it.lproj/Localizable.strings +++ b/Platform/it.lproj/Localizable.strings @@ -2106,3 +2106,172 @@ /* No comment provided by engineer. */ "If enabled, clients must enter a password. This is required if you want to access the server externally." = "Se abilitato, i client dovranno inserire una password. Richiesto se vuoi accedere da client esterni."; + + +/* No comment provided by engineer. */ +"Check for Updates…" = "Controlla Aggiornamenti…"; + +/* UpdateAvailableView */ +"UTM %@ is available" = "UTM %@ è disponibile"; + +/* UpdateAvailableView */ +"Your current version is %@" = "La tua versione attuale è %@"; + +/* UpdateAvailableView */ +"Critical Update" = "Aggiornamento Critico"; + +/* UpdateAvailableView */ +"Beta" = "Beta"; + +/* UpdateAvailableView */ +"What's New" = "Novità"; + +/* UpdateAvailableView */ +"Show Full Release Notes" = "Mostra Note di Rilascio Complete"; + +/* UpdateAvailableView */ +"Installing Update..." = "Installazione Aggiornamento..."; + +/* UpdateAvailableView */ +"UTM will restart when installation is complete" = "UTM si riavvierà al completamento dell'installazione"; + +/* UpdateAvailableView */ +"Skip This Version" = "Salta Questa Versione"; + +/* UpdateAvailableView */ +"Remind Me Later" = "Ricordamelo Dopo"; + +/* UpdateAvailableView */ +"Cancel Download" = "Annulla Download"; + +/* UpdateAvailableView */ +"Download & Install" = "Scarica e Installa"; + +/* UpdateAvailableView */ +"Downloading UTM %@" = "Scaricamento UTM %@"; + +/* UpdateAvailableView */ +"Installing Update" = "Installazione Aggiornamento"; + +/* UpdateAvailableView */ +"Update Failed" = "Aggiornamento Fallito"; + +/* UpdateAvailableView */ +"Please check your internet connection and try again." = "Controlla la tua connessione internet e riprova."; + +/* UpdateAvailableView */ +"Free up some disk space and try again." = "Libera spazio su disco e riprova."; + +/* UpdateAvailableView */ +"This might be a temporary issue. Please try again." = "Potrebbe essere un problema temporaneo. Riprova."; + +/* UpdateAvailableView */ +"Cancel" = "Annulla"; + +/* UpdateAvailableView */ +"Try Again" = "Riprova"; + +/* UpdateSettingsView */ +"Update Preferences" = "Preferenze Aggiornamento"; + +/* UpdateSettingsView */ +"Automatically check for updates" = "Controlla automaticamente per aggiornamenti"; + +/* UpdateSettingsView */ +"UTM will check for updates periodically in the background" = "UTM controllerà per aggiornamenti periodicamente in background"; + +/* UpdateSettingsView */ +"Automatically download updates" = "Scarica automaticamente gli aggiornamenti"; + +/* UpdateSettingsView */ +"Updates will be downloaded automatically when available" = "Gli aggiornamenti verranno scaricati automaticamente quando disponibili"; + +/* UpdateSettingsView */ +"Update Channel" = "Canale di rilascio"; + +/* UpdateSettingsView */ +"Choose which types of releases to receive" = "Scegli che tipo di rilasci ricevere"; + +/* UpdateSettingsView */ +"Include pre-release versions" = "Includi versioni pre-rilascio"; + +/* UpdateSettingsView */ +"Include beta and pre-release versions in update checks" = "Includi versioni beta e pre-rilascio nei controlli aggiornamenti"; + +/* UpdateSettingsView */ +"Update Check" = "Controllo Aggiornamenti"; + +/* UpdateSettingsView */ +"Check for updates:" = "Controlla per aggiornamenti:"; + +/* UpdateSettingsView */ +"Checking..." = "Controllo..."; + +/* UpdateSettingsView */ +"Error: %@" = "Errore: %@"; + +/* UpdateSettingsView */ +"Check Now" = "Controlla Ora"; + +/* UpdateSettingsView */ +"Current Version" = "Versione Attuale"; + +/* UpdateSettingsView */ +"Version" = "Versione"; + +/* UpdateSettingsView */ +"Last checked" = "Ultimo controllo"; + +/* UpdateSettingsView */ +"Status" = "Stato"; + +/* UpdateSettingsView */ +"Update Available" = "Aggiornamento Disponibile"; + +/* UpdateSettingsView */ +"Up to Date" = "Aggiornato"; + +/* UpdateSettingsView */ +"UTM %@" = "UTM %@"; + +/* UpdateSettingsView */ +"Critical" = "Critico"; + +/* UpdateSettingsView */ +"Size: %@" = "Dimensione: %@"; + +/* UpdateSettingsView */ +"Downloading..." = "Scaricamento..."; + +/* UpdateSettingsView */ +"Installing..." = "Installazione..."; + +/* UpdateSettingsView */ +"Release Notes" = "Note di Rilascio"; + +/* UpdateSettingsView */ +"Done" = "Fatto"; + +/* UTMMenuBarExtraScene */ +"Update Available: %@" = "Aggiornamento Disponibile: %@"; + +/* UTMMenuBarExtraScene */ +"Check for Updates" = "Controlla Aggiornamenti"; + +"Updates" = "Aggiornamenti"; + +/* UTMUpdateiOSHandler */ +"A new version of UTM (%@) is available. Since you're using a sideloaded version, please update manually using your preferred sideloading method." = "Una nuova versione di UTM (%@) è disponibile. Dato che stai usando una versione sideloaded, aggiorna manualmente usando il tuo metodo di sideloading preferito."; + +/* UTMUpdateiOSHandler */ +"View Release" = "Visualizza Rilascio"; + +/* UTMUpdateiOSHandler */ +"Copy IPA URL" = "Copia URL IPA"; + +/* UTMUpdateiOSHandler */ +"Later" = "Più Tardi"; + + +/* UpdateSettingsView/UpdateAvailableView */ +"Released %@" = "Rilasciato %@"; diff --git a/Platform/macOS/MarkdownRenderer.swift b/Platform/macOS/MarkdownRenderer.swift new file mode 100644 index 000000000..07c736af9 --- /dev/null +++ b/Platform/macOS/MarkdownRenderer.swift @@ -0,0 +1,679 @@ +// +// Copyright © 2024 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 + +// MARK: - Core Data Models +struct MarkdownElement { + let type: MarkdownElementType + let content: String + let number: Int? + let id = UUID() + + init(type: MarkdownElementType, content: String, number: Int? = nil) { + self.type = type + self.content = content + self.number = number + } +} + +enum MarkdownElementType { + case heading1, heading2, heading3 + case paragraph + case listItem, numberedListItem + case codeBlock + case divider + case table + case link +} + +// MARK: - Parser +class MarkdownParser { + static func parse(_ content: String) -> [MarkdownElement] { + var elements: [MarkdownElement] = [] + let lines = content.components(separatedBy: .newlines) + var currentParagraph = "" + var inCodeBlock = false + var codeBlockContent = "" + var lineIndex = 0 + + while lineIndex < lines.count { + let line = lines[lineIndex] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Handle code blocks + if trimmed.hasPrefix("```") { + if inCodeBlock { + // End code block + if !codeBlockContent.isEmpty { + elements.append(MarkdownElement(type: .codeBlock, content: codeBlockContent.trimmingCharacters(in: .whitespacesAndNewlines))) + codeBlockContent = "" + } + inCodeBlock = false + } else { + // Start code block + if !currentParagraph.isEmpty { + elements.append(MarkdownElement(type: .paragraph, content: currentParagraph.trimmingCharacters(in: .whitespacesAndNewlines))) + currentParagraph = "" + } + inCodeBlock = true + } + lineIndex += 1 + continue + } + + if inCodeBlock { + codeBlockContent += line + "\n" + lineIndex += 1 + continue + } + + // Handle headings + if trimmed.hasPrefix("# ") { + if !currentParagraph.isEmpty { + elements.append(MarkdownElement(type: .paragraph, content: currentParagraph.trimmingCharacters(in: .whitespacesAndNewlines))) + currentParagraph = "" + } + elements.append(MarkdownElement(type: .heading1, content: String(trimmed.dropFirst(2)))) + } else if trimmed.hasPrefix("## ") { + if !currentParagraph.isEmpty { + elements.append(MarkdownElement(type: .paragraph, content: currentParagraph.trimmingCharacters(in: .whitespacesAndNewlines))) + currentParagraph = "" + } + elements.append(MarkdownElement(type: .heading2, content: String(trimmed.dropFirst(3)))) + } else if trimmed.hasPrefix("### ") { + if !currentParagraph.isEmpty { + elements.append(MarkdownElement(type: .paragraph, content: currentParagraph.trimmingCharacters(in: .whitespacesAndNewlines))) + currentParagraph = "" + } + elements.append(MarkdownElement(type: .heading3, content: String(trimmed.dropFirst(4)))) + } else if trimmed.hasPrefix("* ") || trimmed.hasPrefix("- ") { + // Handle list items + if !currentParagraph.isEmpty { + elements.append(MarkdownElement(type: .paragraph, content: currentParagraph.trimmingCharacters(in: .whitespacesAndNewlines))) + currentParagraph = "" + } + elements.append(MarkdownElement(type: .listItem, content: String(trimmed.dropFirst(2)))) + } else if let match = trimmed.range(of: #"^(\d+)\.\s+"#, options: .regularExpression) { + // Handle numbered lists + if !currentParagraph.isEmpty { + elements.append(MarkdownElement(type: .paragraph, content: currentParagraph.trimmingCharacters(in: .whitespacesAndNewlines))) + currentParagraph = "" + } + let numberStr = String(trimmed[.. lineIndex { + // Allow one empty line within table, but continue + tableLineIndex += 1 + if tableLineIndex < lines.count { + let nextLine = lines[tableLineIndex].trimmingCharacters(in: .whitespaces) + if !nextLine.contains("|") { + break // End of table + } + } + } else { + break + } + } + + if !tableContent.isEmpty { + elements.append(MarkdownElement(type: .table, content: tableContent)) + } + + lineIndex = tableLineIndex - 1 + + } else if trimmed == "---" || trimmed == "***" { + // Handle dividers + if !currentParagraph.isEmpty { + elements.append(MarkdownElement(type: .paragraph, content: currentParagraph.trimmingCharacters(in: .whitespacesAndNewlines))) + currentParagraph = "" + } + elements.append(MarkdownElement(type: .divider, content: "")) + } else if trimmed.isEmpty { + // Empty line - end current paragraph + if !currentParagraph.isEmpty { + elements.append(MarkdownElement(type: .paragraph, content: currentParagraph.trimmingCharacters(in: .whitespacesAndNewlines))) + currentParagraph = "" + } + } else { + // Regular text - add to current paragraph + if !currentParagraph.isEmpty { + currentParagraph += " " + } + currentParagraph += trimmed + } + + lineIndex += 1 + } + + // Handle any remaining content + if inCodeBlock && !codeBlockContent.isEmpty { + elements.append(MarkdownElement(type: .codeBlock, content: codeBlockContent.trimmingCharacters(in: .whitespacesAndNewlines))) + } else if !currentParagraph.isEmpty { + elements.append(MarkdownElement(type: .paragraph, content: currentParagraph.trimmingCharacters(in: .whitespacesAndNewlines))) + } + + return elements + } + + static func parsePreview(_ content: String, maxLength: Int) -> [MarkdownElement] { + let previewContent = String(content.prefix(maxLength)) + (content.count > maxLength ? "..." : "") + return parse(previewContent) + } + + static func extractLinkReferences(from content: String) -> [String: String] { + var references: [String: String] = [:] + let lines = content.components(separatedBy: .newlines) + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if let match = trimmed.range(of: #"^\[([^\]]+)\]:\s*(.+)$"#, options: .regularExpression) { + let linkPattern = String(trimmed[match]) + let parts = linkPattern.components(separatedBy: "]: ") + if parts.count == 2 { + let key = String(parts[0].dropFirst()) // Remove [ + let url = parts[1] + references[key] = url + } + } + } + + return references + } +} + +struct TextComponent { + let text: String + let isLink: Bool + let url: URL? + let id = UUID() +} + +// MARK: - Link Text Processor +class LinkTextProcessor { + static func processReferenceLinks(_ text: String, linkReferences: [String: String]) -> String { + var processed = text + + // Handle reference links like [text][ref] + let referencePattern = #"\[([^\]]+)\]\[([^\]]+)\]"# + if let regex = try? NSRegularExpression(pattern: referencePattern) { + let range = NSRange(processed.startIndex..= 3, + let textRange = Range(match.range(at: 1), in: processed), + let refRange = Range(match.range(at: 2), in: processed) { + + let linkText = String(processed[textRange]) + let refKey = String(processed[refRange]) + + if let url = linkReferences[refKey] { + let replacement = "[\(linkText)](\(url))" + if let fullRange = Range(match.range, in: processed) { + processed.replaceSubrange(fullRange, with: replacement) + } + } + } + } + } + + return processed + } + + static func parseTextWithLinks(_ text: String, linkReferences: [String: String]) -> [TextComponent] { + var components: [TextComponent] = [] + let processed = processReferenceLinks(text, linkReferences: linkReferences) + + // Simple link parsing for fallback + let linkPattern = #"\[([^\]]+)\]\(([^)]+)\)"# + if let regex = try? NSRegularExpression(pattern: linkPattern) { + let range = NSRange(processed.startIndex..= 3, + let textRange = Range(match.range(at: 1), in: processed), + let urlRange = Range(match.range(at: 2), in: processed) { + + let linkText = String(processed[textRange]) + let urlString = String(processed[urlRange]) + let url = URL(string: urlString) + + components.append(TextComponent(text: linkText, isLink: true, url: url)) + } + + lastEnd = matchRange.upperBound + } + } + + // Add remaining text + if lastEnd < processed.endIndex { + let remainingText = String(processed[lastEnd...]) + if !remainingText.isEmpty { + components.append(TextComponent(text: remainingText, isLink: false, url: nil)) + } + } + } else { + components.append(TextComponent(text: processed, isLink: false, url: nil)) + } + + return components.isEmpty ? [TextComponent(text: processed, isLink: false, url: nil)] : components + } +} + +// MARK: - Full-Featured Markdown Renderer +struct MarkdownRenderer: View { + let content: String + let linkReferences: [String: String] + + init(content: String, linkReferences: [String: String] = [:]) { + self.content = content + self.linkReferences = linkReferences.isEmpty ? MarkdownParser.extractLinkReferences(from: content) : linkReferences + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(MarkdownParser.parse(content), id: \.id) { element in + switch element.type { + case .heading1: + if #available(macOS 12, *) { + Text(element.content) + .font(.title) + .fontWeight(.bold) + .padding(.top, 8) + .textSelection(.enabled) + } else { + Text(element.content) + .font(.title) + .fontWeight(.bold) + .padding(.top, 8) + } + + case .heading2: + if #available(macOS 12, *) { + Text(element.content) + .font(.title2) + .fontWeight(.semibold) + .padding(.top, 6) + .textSelection(.enabled) + } else { + Text(element.content) + .font(.title2) + .fontWeight(.semibold) + .padding(.top, 6) + } + + case .heading3: + if #available(macOS 12, *) { + Text(element.content) + .font(.title3) + .fontWeight(.medium) + .padding(.top, 4) + .textSelection(.enabled) + } else { + Text(element.content) + .font(.title3) + .fontWeight(.medium) + .padding(.top, 4) + } + + case .listItem: + HStack(alignment: .top, spacing: 8) { + Text("•") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.accentColor) + .padding(.top, 2) + + MarkdownFormattedText(content: element.content, linkReferences: linkReferences) + + Spacer() + } + .padding(.leading, 8) + + case .numberedListItem: + HStack(alignment: .top, spacing: 8) { + Text("\(element.number ?? 1).") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.accentColor) + .padding(.top, 2) + + MarkdownFormattedText(content: element.content, linkReferences: linkReferences) + + Spacer() + } + .padding(.leading, 8) + + case .codeBlock: + if #available(macOS 12, *) { + Text(element.content) + .font(.system(.body, design: .monospaced)) + .padding(12) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(8) + .textSelection(.enabled) + } else { + Text(element.content) + .font(.system(.body, design: .monospaced)) + .padding(12) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(8) + } + + case .paragraph: + MarkdownFormattedText(content: element.content, linkReferences: linkReferences) + + case .table: + MarkdownTable(content: element.content) + + case .divider: + Divider() + .padding(.vertical, 4) + + case .link: + EmptyView() + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Preview Markdown Renderer with Length Limits +struct MarkdownPreview: View { + let content: String + let maxLength: Int + + var body: some View { + let elements = MarkdownParser.parsePreview(content, maxLength: maxLength) + + VStack(alignment: .leading, spacing: 8) { + ForEach(elements, id: \.id) { element in + switch element.type { + case .heading2: + Text(element.content) + .font(.subheadline) + .fontWeight(.semibold) + .padding(.top, 4) + + case .heading3: + Text(element.content) + .font(.subheadline) + .fontWeight(.medium) + .padding(.top, 2) + + case .listItem: + HStack(alignment: .top, spacing: 6) { + Text("•") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.accentColor) + .padding(.top, 2) + + if #available(macOS 12, *), let attributed = try? AttributedString(markdown: element.content) { + Text(attributed) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(element.content) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + } + .padding(.leading, 6) + + case .numberedListItem: + HStack(alignment: .top, spacing: 6) { + Text("\(element.number ?? 1).") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.accentColor) + .padding(.top, 1) + + if #available(macOS 12, *), let attributed = try? AttributedString(markdown: element.content) { + Text(attributed) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(element.content) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + } + .padding(.leading, 6) + + case .paragraph: + if #available(macOS 12, *), let attributed = try? AttributedString(markdown: element.content) { + Text(attributed) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(element.content) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } + + default: + EmptyView() + } + } + } + .lineLimit(5) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Text-Only Renderer +struct MarkdownText: View { + let content: String + let linkReferences: [String: String] + + var body: some View { + if #available(macOS 12, *) { + let processedContent = LinkTextProcessor.processReferenceLinks(content, linkReferences: linkReferences) + + if let attributedString = try? AttributedString(markdown: processedContent) { + Text(attributedString) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(processedContent) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } else { + // Fallback for older macOS versions + Text(content) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +// MARK: - Formatted Text Renderer +struct MarkdownFormattedText: View { + let content: String + let linkReferences: [String: String] + + var body: some View { + if #available(macOS 12, *) { + // Use AttributedString for full markdown formatting, then add custom link handling + let processedContent = LinkTextProcessor.processReferenceLinks(content, linkReferences: linkReferences) + + if let attributedString = try? AttributedString(markdown: processedContent) { + // Check if the text contains links that need custom cursor behavior + if processedContent.contains("[") && processedContent.contains("](") { + // Text has links - use custom rendering for proper cursor behavior + MarkdownTextWithCustomLinks(content: processedContent, linkReferences: linkReferences) + } else { + // No links - use simple AttributedString rendering + Text(attributedString) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } else { + // Fallback to simple text rendering + MarkdownText(content: content, linkReferences: linkReferences) + } + } else { + // Fallback for older macOS versions + MarkdownText(content: content, linkReferences: linkReferences) + } + } +} + +// MARK: - Custom Link Handling for macOS 12+ +@available(macOS 12, *) +struct MarkdownTextWithCustomLinks: View { + let content: String + let linkReferences: [String: String] + + var body: some View { + // Parse the text into components (plain and links) + let components = LinkTextProcessor.parseTextWithLinks(content, linkReferences: linkReferences) + HStack(spacing: 0) { + ForEach(components, id: \.id) { component in + if component.isLink, let url = component.url { + StyledLink(text: component.text, url: url) + } else { + Text(component.text) + } + } + } + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } +} + +// MARK: - Styled Link Component +private struct StyledLink: View { + let text: String + let url: URL + + var body: some View { + if #available(macOS 13, *) { + Link(text, destination: url) + .underline(true, color: .blue) + .foregroundColor(.blue) + } else { + Link(text, destination: url) + .foregroundColor(.blue) + } + } +} + +// MARK: - Table Renderer +struct MarkdownTable: View { + let content: String + + var body: some View { + let rows = parseTable(content) + + if !rows.isEmpty { + VStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + HStack(spacing: 0) { + ForEach(Array(row.enumerated()), id: \.offset) { cellIndex, cell in + if #available(macOS 13, *) { + Text(cell.trimmingCharacters(in: .whitespaces)) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(index == 0 ? Color.secondary.opacity(0.1) : Color.clear) + .fontWeight(index == 0 ? .semibold : .regular) + } else { + Text(cell.trimmingCharacters(in: .whitespaces)) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(index == 0 ? Color.secondary.opacity(0.1) : Color.clear) + .font(index == 0 ? .body.weight(.semibold) : .body) + } + + if cellIndex < row.count - 1 { + Divider() + } + } + } + + if index < rows.count - 1 { + Divider() + } + } + } + .overlay( + Rectangle() + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + .cornerRadius(4) + } + } + + private func parseTable(_ content: String) -> [[String]] { + let lines = content.components(separatedBy: .newlines) + var rows: [[String]] = [] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.contains("|") && !trimmed.isEmpty { + // Skip separator lines (like |---|---|) + if trimmed.contains("---") { + continue + } + + let cells = trimmed.components(separatedBy: "|") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + if !cells.isEmpty { + rows.append(cells) + } + } + } + + return rows + } +} diff --git a/Platform/macOS/SettingsView.swift b/Platform/macOS/SettingsView.swift index 99c2a5c57..814584f21 100644 --- a/Platform/macOS/SettingsView.swift +++ b/Platform/macOS/SettingsView.swift @@ -20,6 +20,7 @@ import SwiftUI struct SettingsView: View { private enum Selection: CaseIterable, Identifiable { case application + case updates case display case sound case input @@ -44,6 +45,8 @@ struct SettingsView: View { switch self { case .application: return "Application" + case .updates: + return "Updates" case .display: return "Display" case .sound: @@ -63,6 +66,8 @@ struct SettingsView: View { switch self { case .application: return "app.badge" + case .updates: + return "arrow.down.circle" case .display: return "rectangle.on.rectangle" case .sound: @@ -83,6 +88,8 @@ struct SettingsView: View { switch self { case .application: ApplicationSettingsView() + case .updates: + UpdateSettingsView() case .display: DisplaySettingsView() case .sound: diff --git a/Platform/macOS/UTMMenuBarExtraScene.swift b/Platform/macOS/UTMMenuBarExtraScene.swift index 74b69b045..8cdcdc8be 100644 --- a/Platform/macOS/UTMMenuBarExtraScene.swift +++ b/Platform/macOS/UTMMenuBarExtraScene.swift @@ -20,33 +20,69 @@ import SwiftUI @available(macOS 13, *) struct UTMMenuBarExtraScene: Scene { @ObservedObject var data: UTMData + @StateObject private var updateManager = UTMUpdateManager.shared @AppStorage("ShowMenuIcon") private var isMenuIconShown: Bool = false @AppStorage("HideDockIcon") private var isDockIconHidden: Bool = false @Environment(\.openWindow) private var openWindow var body: some Scene { MenuBarExtra(isInserted: $isMenuIconShown) { - Button("Show UTM") { + + Button(NSLocalizedString("Show UTM", comment: "UTMMenuBarExtraScene")) { openWindow(id: "home") }.keyboardShortcut("0") - .help("Show the main window.") - Toggle("Hide dock icon on next launch", isOn: $isDockIconHidden) - .help("Requires restarting UTM to take affect.") + + .help(NSLocalizedString("Show the main window.", comment: "UTMMenuBarExtraScene")) + + if updateManager.isUpdateAvailable { + + Button(String.localizedStringWithFormat(NSLocalizedString("Update Available: %@", comment: "UTMMenuBarExtraScene"), updateManager.latestVersion)) { + openWindow(id: "settings") + } + .foregroundColor(.accentColor) + Divider() + } + + + Button(NSLocalizedString("Check for Updates", comment: "UTMMenuBarExtraScene")) { + Task { + await updateManager.checkForUpdates(force: true) + } + } + .disabled(updateManager.isCheckingForUpdates) + + + Toggle(NSLocalizedString("Hide dock icon on next launch", comment: "UTMMenuBarExtraScene"), isOn: $isDockIconHidden) + + .help(NSLocalizedString("Requires restarting UTM to take affect.", comment: "UTMMenuBarExtraScene")) Divider() if data.virtualMachines.isEmpty { - Text("No virtual machines found.") + + Text(NSLocalizedString("No virtual machines found.", comment: "UTMMenuBarExtraScene")) } else { ForEach(data.virtualMachines) { vm in VMMenuItem(vm: vm).environmentObject(data) } } Divider() - Button("Quit") { + + Button(NSLocalizedString("Quit", comment: "UTMMenuBarExtraScene")) { NSApp.terminate(self) }.keyboardShortcut("Q") - .help("Terminate UTM and stop all running VMs.") + + .help(NSLocalizedString("Terminate UTM and stop all running VMs.", comment: "UTMMenuBarExtraScene")) } label: { - Image("MenuBarExtra") + ZStack { + Image("MenuBarExtra") + + // Update indicator + if updateManager.isUpdateAvailable { + Circle() + .fill(Color.red) + .frame(width: 8, height: 8) + .offset(x: 8, y: -8) + } + } } } } @@ -58,22 +94,27 @@ private struct VMMenuItem: View { var body: some View { Menu(vm.detailsTitleLabel) { if vm.isStopped { - Button("Start") { + + Button(NSLocalizedString("Start", comment: "UTMMenuBarExtraScene")) { data.run(vm: vm) } } else if !vm.isBusy { - Button("Stop") { + + Button(NSLocalizedString("Stop", comment: "UTMMenuBarExtraScene")) { data.stop(vm: vm) } - Button("Suspend") { + + Button(NSLocalizedString("Suspend", comment: "UTMMenuBarExtraScene")) { let isSnapshot = (vm.wrapped as? UTMQemuVirtualMachine)?.isRunningAsDisposible ?? false vm.wrapped!.requestVmPause(save: !isSnapshot) } - Button("Reset") { + + Button(NSLocalizedString("Reset", comment: "UTMMenuBarExtraScene")) { vm.wrapped!.requestVmReset() } } else { - Text("Busy…") + + Text(NSLocalizedString("Busy…", comment: "UTMMenuBarExtraScene")) } } } diff --git a/Platform/macOS/UpdateAvailableView.swift b/Platform/macOS/UpdateAvailableView.swift new file mode 100644 index 000000000..17122a8ec --- /dev/null +++ b/Platform/macOS/UpdateAvailableView.swift @@ -0,0 +1,309 @@ +// +// Copyright © 2024 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 UpdateAvailableView: View { + let updateInfo: UTMUpdateManager.UpdateInfo + @ObservedObject var updateManager: UTMUpdateManager + @Environment(\.presentationMode) var presentationMode + @State private var showReleaseNotes = false + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.accentColor) + + VStack(spacing: 8) { + + Text(String.localizedStringWithFormat(NSLocalizedString("UTM %@ is available", comment: "UpdateAvailableView"), updateInfo.version)) + .font(.title2) + .fontWeight(.semibold) + + + Text(String.localizedStringWithFormat(NSLocalizedString("Your current version is %@", comment: "UpdateAvailableView"), updateManager.currentVersion)) + .font(.subheadline) + .foregroundColor(.secondary) + + HStack { + Text(String.localizedStringWithFormat(NSLocalizedString("Released %@", comment: "UpdateAvailableView"), updateInfo.releaseDate.abbreviatedDateString)) + + if updateInfo.isCritical { + + Label(NSLocalizedString("Critical Update", comment: "UpdateAvailableView"), systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.red) + .font(.caption) + } + + if updateInfo.isPrerelease { + + Label(NSLocalizedString("Beta", comment: "UpdateAvailableView"), systemImage: "hammer.fill") + .foregroundColor(.orange) + .font(.caption) + } + } + .font(.caption) + .foregroundColor(.secondary) + } + + // Release notes summary + ScrollView { + VStack(alignment: .leading, spacing: 12) { + HStack { + + Text(NSLocalizedString("What's New", comment: "UpdateAvailableView")) + .font(.headline) + Spacer() + + Button(NSLocalizedString("Show Full Release Notes", comment: "UpdateAvailableView")) { + showReleaseNotes = true + } + .font(.caption) + } + + MarkdownPreview(content: updateInfo.releaseNotes, maxLength: 300) + .padding(.top, 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .frame(maxHeight: 150) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(8) + + if updateManager.isDownloading { + UpdateProgressView(updateManager: updateManager) + } else if updateManager.isInstalling { + VStack(spacing: 8) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + + Text(NSLocalizedString("Installing Update...", comment: "UpdateAvailableView")) + .font(.headline) + + Text(NSLocalizedString("UTM will restart when installation is complete", comment: "UpdateAvailableView")) + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color.secondary.opacity(0.1)) + .cornerRadius(8) + } + + // Error display + if let error = updateManager.updateError { + VStack { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(error.localizedDescription) + .font(.caption) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.red.opacity(0.1)) + .cornerRadius(6) + } + + Spacer() + + // Action buttons + HStack(spacing: 12) { + + Button(NSLocalizedString("Skip This Version", comment: "UpdateAvailableView")) { + updateManager.skipVersion(updateInfo.version) + presentationMode.wrappedValue.dismiss() + } + + + Button(NSLocalizedString("Remind Me Later", comment: "UpdateAvailableView")) { + presentationMode.wrappedValue.dismiss() + } + + if updateManager.isDownloading { + + Button(NSLocalizedString("Cancel Download", comment: "UpdateAvailableView")) { + updateManager.cancelDownload() + } + } else if !updateManager.isInstalling { + if #available(macOS 12.0, *) { + + Button(NSLocalizedString("Download & Install", comment: "UpdateAvailableView")) { + Task { + await updateManager.downloadAndInstall() + } + } + .buttonStyle(.borderedProminent) + .disabled(updateManager.isDownloading || updateManager.isInstalling) + } else { + + Button(NSLocalizedString("Download & Install", comment: "UpdateAvailableView")) { + Task { + await updateManager.downloadAndInstall() + } + } + .keyboardShortcut(.defaultAction) + .disabled(updateManager.isDownloading || updateManager.isInstalling) + } + } + } + } + .padding() + .frame(width: 480, height: 520) + .sheet(isPresented: $showReleaseNotes) { + UpdateReleaseNotesView(updateInfo: updateInfo) + } + } +} + +struct UpdateProgressView: View { + @ObservedObject var updateManager: UTMUpdateManager + + var body: some View { + VStack(spacing: 12) { + if updateManager.isDownloading { + VStack(spacing: 8) { + + Text(String.localizedStringWithFormat(NSLocalizedString("Downloading UTM %@", comment: "UpdateAvailableView"), updateManager.latestVersion)) + .font(.headline) + + ProgressView(value: updateManager.downloadProgress) + .progressViewStyle(LinearProgressViewStyle()) + + HStack { + Text("\(Int(updateManager.downloadProgress * 100))%") + Spacer() + // download speed and time remaining could be added here if needed + } + .font(.caption) + .foregroundColor(.secondary) + } + } else if updateManager.isInstalling { + VStack(spacing: 8) { + + Text(NSLocalizedString("Installing Update", comment: "UpdateAvailableView")) + .font(.headline) + + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + + + Text(NSLocalizedString("UTM will restart when installation is complete", comment: "UpdateAvailableView")) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .padding() + .background(Color.secondary.opacity(0.1)) + .cornerRadius(8) + } +} + +struct UpdateErrorView: View { + let error: UTMUpdateManager.UpdateError + let onRetry: () -> Void + let onDismiss: () -> Void + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 50)) + .foregroundColor(.red) + + + Text(NSLocalizedString("Update Failed", comment: "UpdateAvailableView")) + .font(.title2) + .fontWeight(.semibold) + + Text(error.localizedDescription) + .font(.body) + .multilineTextAlignment(.center) + .padding(.horizontal) + + switch error { + case .networkUnavailable: + + Text(NSLocalizedString("Please check your internet connection and try again.", comment: "UpdateAvailableView")) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + case .insufficientSpace: + + Text(NSLocalizedString("Free up some disk space and try again.", comment: "UpdateAvailableView")) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + case .downloadFailed, .verificationFailed: + + Text(NSLocalizedString("This might be a temporary issue. Please try again.", comment: "UpdateAvailableView")) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + default: + EmptyView() + } + + HStack(spacing: 12) { + + Button(NSLocalizedString("Cancel", comment: "UpdateAvailableView")) { + onDismiss() + } + + if #available(macOS 12.0, *) { + + Button(NSLocalizedString("Try Again", comment: "UpdateAvailableView")) { + onRetry() + } + .buttonStyle(.borderedProminent) + } else { + + Button(NSLocalizedString("Try Again", comment: "UpdateAvailableView")) { + onRetry() + } + .keyboardShortcut(.defaultAction) + } + } + } + .padding() + .frame(width: 400, height: 300) + } +} + +// MARK: - Previews +struct UpdateAvailableView_Previews: PreviewProvider { + static var previews: some View { + UpdateAvailableView( + updateInfo: UTMUpdateManager.UpdateInfo( + version: "4.5.0", + releaseDate: Date(), + downloadURL: URL(string: "https://github.com/utmapp/UTM/releases/download/v4.5.0/UTM.dmg")!, + releaseNotes: "## New Features\n\n* Added support for new CPU architectures\n* Improved performance\n* Bug fixes", + fileSize: 150_000_000, + minimumSystemVersion: "11.0", + isCritical: false, + isPrerelease: false, + assets: [] + ), + updateManager: UTMUpdateManager.shared + ) + } +} diff --git a/Platform/macOS/UpdateSettingsView.swift b/Platform/macOS/UpdateSettingsView.swift new file mode 100644 index 000000000..616b90f0a --- /dev/null +++ b/Platform/macOS/UpdateSettingsView.swift @@ -0,0 +1,360 @@ +// +// Copyright © 2024 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 + +// MARK: - Date Formatting Extension +extension Date { + var abbreviatedDateString: String { + if #available(macOS 12, *) { + return self.formatted(date: .abbreviated, time: .omitted) + } else { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: self) + } + } +} + +struct UpdateSettingsView: View { + @StateObject private var updateManager = UTMUpdateManager.shared + @AppStorage("AutoCheckForUpdates") var autoCheckForUpdates = true + @AppStorage("AutoDownloadUpdates") var autoDownloadUpdates = false + @AppStorage("NotifyPreRelease") var notifyPreRelease = false + @AppStorage("UpdateChannel") var updateChannelRaw: String = UTMUpdateManager.UpdateChannel.stable.rawValue + + private var updateChannel: UTMUpdateManager.UpdateChannel { + UTMUpdateManager.UpdateChannel(rawValue: updateChannelRaw) ?? .stable + } + + var body: some View { + Form { + + Section(header: Text(NSLocalizedString("Update Preferences", comment: "UpdateSettingsView"))) { + + Toggle(NSLocalizedString("Automatically check for updates", comment: "UpdateSettingsView"), isOn: $autoCheckForUpdates) + + .help(NSLocalizedString("UTM will check for updates periodically in the background", comment: "UpdateSettingsView")) + + + Toggle(NSLocalizedString("Automatically download updates", comment: "UpdateSettingsView"), isOn: $autoDownloadUpdates) + .disabled(!autoCheckForUpdates) + + .help(NSLocalizedString("Updates will be downloaded automatically when available", comment: "UpdateSettingsView")) + + + Picker(NSLocalizedString("Update Channel", comment: "UpdateSettingsView"), selection: Binding( + get: { updateChannel }, + set: { updateChannelRaw = $0.rawValue } + )) { + ForEach(UTMUpdateManager.UpdateChannel.allCases, id: \.self) { channel in + Text(channel.displayName).tag(channel) + } + } + + .help(NSLocalizedString("Choose which types of releases to receive", comment: "UpdateSettingsView")) + + + Toggle(NSLocalizedString("Include pre-release versions", comment: "UpdateSettingsView"), isOn: $notifyPreRelease) + .disabled(updateChannel != .all) + + .help(NSLocalizedString("Include beta and pre-release versions in update checks", comment: "UpdateSettingsView")) + } + + + Section(header: Text(NSLocalizedString("Update Check", comment: "UpdateSettingsView"))) { + HStack { + VStack(alignment: .leading) { + + Text(NSLocalizedString("Check for updates:", comment: "UpdateSettingsView")) + if updateManager.isCheckingForUpdates { + + Text(NSLocalizedString("Checking...", comment: "UpdateSettingsView")) + .font(.caption) + .foregroundColor(.secondary) + } else if let error = updateManager.updateError { + + Text(String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "UpdateSettingsView"), error.localizedDescription)) + .font(.caption) + .foregroundColor(.red) + } + } + Spacer() + + Button(NSLocalizedString("Check Now", comment: "UpdateSettingsView")) { + Task { + await updateManager.checkForUpdates(force: true) + } + } + .disabled(updateManager.isCheckingForUpdates) + } + } + + if let updateInfo = updateManager.updateInfo { + UpdateAvailableSection(updateInfo: updateInfo, updateManager: updateManager) + } + + + Section(header: Text(NSLocalizedString("Current Version", comment: "UpdateSettingsView"))) { + HStack { + + Text(NSLocalizedString("Version", comment: "UpdateSettingsView")) + Spacer() + Text(updateManager.currentVersion) + .foregroundColor(.secondary) + } + + if let lastCheck = updateManager.lastUpdateCheckDate { + HStack { + + Text(NSLocalizedString("Last checked", comment: "UpdateSettingsView")) + Spacer() + Text(lastCheck, style: .relative) + .foregroundColor(.secondary) + } + } + + if updateManager.isUpdateAvailable { + HStack { + + Text(NSLocalizedString("Status", comment: "UpdateSettingsView")) + Spacer() + + Label(NSLocalizedString("Update Available", comment: "UpdateSettingsView"), systemImage: "exclamationmark.circle.fill") + .foregroundColor(.orange) + } + } else if !updateManager.isCheckingForUpdates && updateManager.updateError == nil { + HStack { + + Text(NSLocalizedString("Status", comment: "UpdateSettingsView")) + Spacer() + + Label(NSLocalizedString("Up to Date", comment: "UpdateSettingsView"), systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + } + } + } + } + .onAppear { + // perform initial update check if it's been a while (more than 1 hour) + if updateManager.lastUpdateCheckDate == nil || + Date().timeIntervalSince(updateManager.lastUpdateCheckDate ?? Date.distantPast) > 3600 { + Task { + await updateManager.checkForUpdates() + } + } + } + } +} + +struct UpdateAvailableSection: View { + let updateInfo: UTMUpdateManager.UpdateInfo + @ObservedObject var updateManager: UTMUpdateManager + @State private var showReleaseNotes = false + + var body: some View { + + Section(header: Text(NSLocalizedString("Update Available", comment: "UpdateSettingsView"))) { + VStack(alignment: .leading, spacing: 8) { + HStack { + + Label(String.localizedStringWithFormat(NSLocalizedString("UTM %@", comment: "UpdateSettingsView"), updateInfo.version), systemImage: "arrow.down.circle.fill") + .font(.headline) + .foregroundColor(.accentColor) + + Spacer() + + if updateInfo.isCritical { + + Label(NSLocalizedString("Critical", comment: "UpdateSettingsView"), systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.red) + } + + if updateInfo.isPrerelease { + + Label(NSLocalizedString("Beta", comment: "UpdateSettingsView"), systemImage: "hammer.fill") + .font(.caption) + .foregroundColor(.orange) + } + } + + Text(String.localizedStringWithFormat(NSLocalizedString("Released %@", comment: "UpdateSettingsView"), updateInfo.releaseDate.abbreviatedDateString)) + .font(.caption) + .foregroundColor(.secondary) + + + Text(String.localizedStringWithFormat(NSLocalizedString("Size: %@", comment: "UpdateSettingsView"), ByteCountFormatter.string(fromByteCount: updateInfo.fileSize, countStyle: .file))) + .font(.caption) + .foregroundColor(.secondary) + + if updateManager.isDownloading { + ProgressView(value: updateManager.downloadProgress) { + HStack { + + Text(NSLocalizedString("Downloading...", comment: "UpdateSettingsView")) + Spacer() + Text("\(Int(updateManager.downloadProgress * 100))%") + } + .font(.caption) + } + } else if updateManager.isInstalling { + HStack { + ProgressView() + .scaleEffect(0.5) + + Text(NSLocalizedString("Installing...", comment: "UpdateSettingsView")) + .font(.caption) + } + } + + HStack { + + Button(NSLocalizedString("Release Notes", comment: "UpdateSettingsView")) { + showReleaseNotes = true + } + + + Button(NSLocalizedString("Skip This Version", comment: "UpdateSettingsView")) { + updateManager.skipVersion(updateInfo.version) + } + + Spacer() + + if updateManager.isDownloading { + + Button(NSLocalizedString("Cancel", comment: "UpdateSettingsView")) { + updateManager.cancelDownload() + } + } else if !updateManager.isInstalling { + if #available(macOS 12.0, *) { + + Button(NSLocalizedString("Download & Install", comment: "UpdateSettingsView")) { + Task { + await updateManager.downloadAndInstall() + } + } + .buttonStyle(.borderedProminent) + } else { + + Button(NSLocalizedString("Download & Install", comment: "UpdateSettingsView")) { + Task { + await updateManager.downloadAndInstall() + } + } + .buttonStyle(.automatic) + } + } + } + } + .padding(.vertical, 4) + } + .sheet(isPresented: $showReleaseNotes) { + UpdateReleaseNotesView(updateInfo: updateInfo) + } + } +} + +struct UpdateReleaseNotesView: View { + let updateInfo: UTMUpdateManager.UpdateInfo + @Environment(\.presentationMode) private var presentationMode + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading) { + + Text(NSLocalizedString("Release Notes", comment: "UpdateSettingsView")) + .font(.title2) + .fontWeight(.semibold) + } + + Spacer() + + + Button(NSLocalizedString("Done", comment: "UpdateSettingsView")) { + presentationMode.wrappedValue.dismiss() + } + .keyboardShortcut(.cancelAction) + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(Color.secondary.opacity(0.3)), + alignment: .bottom + ) + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Version header + HStack { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 48)) + .foregroundColor(.accentColor) + + VStack(alignment: .leading, spacing: 4) { + + Text(String.localizedStringWithFormat(NSLocalizedString("UTM %@", comment: "UpdateSettingsView"), updateInfo.version)) + .font(.title) + .fontWeight(.semibold) + + HStack { + Text(String.localizedStringWithFormat(NSLocalizedString("Released %@", comment: "UpdateSettingsView"), updateInfo.releaseDate.abbreviatedDateString)) + .font(.subheadline) + .foregroundColor(.secondary) + + if updateInfo.isCritical { + + Label(NSLocalizedString("Critical Update", comment: "UpdateSettingsView"), systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.red) + .font(.caption) + } + + if updateInfo.isPrerelease { + + Label(NSLocalizedString("Beta", comment: "UpdateSettingsView"), systemImage: "hammer.fill") + .foregroundColor(.orange) + .font(.caption) + } + } + } + + Spacer() + } + .padding(.bottom, 8) + + Divider() + + MarkdownRenderer(content: updateInfo.releaseNotes) + + Spacer(minLength: 20) + } + .padding() + } + } + .frame(width: 600, height: 500) + .background(Color(NSColor.textBackgroundColor)) + } +} + +struct UpdateSettingsView_Previews: PreviewProvider { + static var previews: some View { + UpdateSettingsView() + } +} diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 0233333c7..6050a5121 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -280,6 +280,32 @@ 85EC516427CC8D0F004A51DE /* VMConfigAdvancedNetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EC516327CC8C98004A51DE /* VMConfigAdvancedNetworkView.swift */; }; 85EC516527CC8D0F004A51DE /* VMConfigAdvancedNetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EC516327CC8C98004A51DE /* VMConfigAdvancedNetworkView.swift */; }; 85EC516627CC8D10004A51DE /* VMConfigAdvancedNetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EC516327CC8C98004A51DE /* VMConfigAdvancedNetworkView.swift */; }; + 863B2F6E2E411832009CD2BD /* UpdateSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 863B2F6D2E411832009CD2BD /* UpdateSettingsView.swift */; }; + 863B2F702E411BEE009CD2BD /* UpdateAvailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 863B2F6F2E411BEE009CD2BD /* UpdateAvailableView.swift */; }; + 863B2F722E411FD7009CD2BD /* UTMUpdateiOSHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 863B2F712E411FD7009CD2BD /* UTMUpdateiOSHandler.swift */; }; + 863B2F732E411FD7009CD2BD /* UTMUpdateiOSHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 863B2F712E411FD7009CD2BD /* UTMUpdateiOSHandler.swift */; }; + 863B2F742E411FD7009CD2BD /* UTMUpdateiOSHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 863B2F712E411FD7009CD2BD /* UTMUpdateiOSHandler.swift */; }; + 868B9CB82E437EB4001509C3 /* UTMUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CB72E437EB4001509C3 /* UTMUpdateManager.swift */; }; + 868B9CB92E437EB4001509C3 /* UTMUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CB72E437EB4001509C3 /* UTMUpdateManager.swift */; }; + 868B9CBA2E437EB4001509C3 /* UTMUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CB72E437EB4001509C3 /* UTMUpdateManager.swift */; }; + 868B9CBB2E437EB4001509C3 /* UTMUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CB72E437EB4001509C3 /* UTMUpdateManager.swift */; }; + 868B9CBF2E437F3A001509C3 /* UTMInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */; }; + 868B9CC02E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */; }; + 868B9CC12E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */; }; + 868B9CC22E437F3A001509C3 /* UTMInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */; }; + 868B9CC32E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */; }; + 868B9CC42E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */; }; + 868B9CC52E437F3A001509C3 /* UTMInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */; }; + 868B9CC62E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */; }; + 868B9CC72E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */; }; + 868B9CC82E437F3A001509C3 /* UTMInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */; }; + 868B9CC92E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */; }; + 868B9CCA2E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */; }; + 868B9CCC2E438027001509C3 /* UTMDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CCB2E438027001509C3 /* UTMDownloadManager.swift */; }; + 868B9CCD2E438027001509C3 /* UTMDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CCB2E438027001509C3 /* UTMDownloadManager.swift */; }; + 868B9CCE2E438027001509C3 /* UTMDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CCB2E438027001509C3 /* UTMDownloadManager.swift */; }; + 868B9CCF2E438027001509C3 /* UTMDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CCB2E438027001509C3 /* UTMDownloadManager.swift */; }; + 86DFBFDC2E423923001F5A61 /* MarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86DFBFDB2E42391E001F5A61 /* MarkdownRenderer.swift */; }; B329049C270FE136002707AC /* AltKit in Frameworks */ = {isa = PBXBuildFile; productRef = B329049B270FE136002707AC /* AltKit */; }; B3DDF57226E9BBA300CE47F0 /* AltKit in Frameworks */ = {isa = PBXBuildFile; productRef = B3DDF57126E9BBA300CE47F0 /* AltKit */; }; CD77BE422CAB51B40074ADD2 /* UTMScriptingExportCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD77BE412CAB519F0074ADD2 /* UTMScriptingExportCommand.swift */; }; @@ -1813,6 +1839,15 @@ 84F746BA276FF70700A20C87 /* VMDisplayQemuDisplayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayQemuDisplayController.swift; sourceTree = ""; }; 84F909FE289488F90008DBE2 /* MenuLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuLabel.swift; sourceTree = ""; }; 85EC516327CC8C98004A51DE /* VMConfigAdvancedNetworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigAdvancedNetworkView.swift; sourceTree = ""; }; + 863B2F6D2E411832009CD2BD /* UpdateSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateSettingsView.swift; sourceTree = ""; }; + 863B2F6F2E411BEE009CD2BD /* UpdateAvailableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateAvailableView.swift; sourceTree = ""; }; + 863B2F712E411FD7009CD2BD /* UTMUpdateiOSHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMUpdateiOSHandler.swift; sourceTree = ""; }; + 868B9CB72E437EB4001509C3 /* UTMUpdateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMUpdateManager.swift; sourceTree = ""; }; + 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMInstaller.swift; sourceTree = ""; }; + 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMUpdateErrorHandler.swift; sourceTree = ""; }; + 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMUpdateSecurity.swift; sourceTree = ""; }; + 868B9CCB2E438027001509C3 /* UTMDownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDownloadManager.swift; sourceTree = ""; }; + 86DFBFDB2E42391E001F5A61 /* MarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownRenderer.swift; sourceTree = ""; }; 9786BB59294056960032B858 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; C03453AD2709E35100AD51AD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; C03453AE2709E35100AD51AD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; @@ -2727,6 +2762,7 @@ CE2D953B24AD4F980059923A /* macOS */ = { isa = PBXGroup; children = ( + 863B2F6D2E411832009CD2BD /* UpdateSettingsView.swift */, CE1BD9FA24F4825C0022A468 /* Display */, CEECE13B25E47D9500A2AAB8 /* AppDelegate.swift */, CEB20EE9282053320033EFB5 /* DoubleClickHandler.swift */, @@ -2734,6 +2770,7 @@ CE2D955524AD4F980059923A /* UTMApp.swift */, CEBBF1A624B5730F00C15049 /* UTMDataExtension.swift */, 84E3A91A2946D2590024A740 /* UTMMenuBarExtraScene.swift */, + 863B2F6F2E411BEE009CD2BD /* UpdateAvailableView.swift */, CEB54C802931C43F000D2AA9 /* UTMPatches.swift */, CEE06B262B2FC89400A811AE /* UTMServerView.swift */, 8401FD9F269D266E00265F0D /* VMConfigAppleBootView.swift */, @@ -2757,6 +2794,7 @@ CEF0300526A25A6900667B63 /* VMWizardView.swift */, 84BB99392899E8D500DF28B2 /* VMHeadlessSessionState.swift */, CEFE96762B69A7CC000F00C9 /* VMRemoteSessionState.swift */, + 86DFBFDB2E42391E001F5A61 /* MarkdownRenderer.swift */, 53A0BDD426D79FE40010EDC5 /* SavePanel.swift */, CE2D954124AD4F980059923A /* Info.plist */, FFB02A8E266CB09C006CD71A /* InfoPlist.strings */, @@ -2770,6 +2808,7 @@ CE2D954A24AD4F980059923A /* iOS */ = { isa = PBXGroup; children = ( + 863B2F712E411FD7009CD2BD /* UTMUpdateiOSHandler.swift */, CE7BED4D22600F5000A1E1B6 /* Display */, CE8813D224CD230300532628 /* ActivityView.swift */, CED814EE24C7EB760042F0F1 /* ImagePicker.swift */, @@ -3016,6 +3055,11 @@ CEB63A9624F47C1200CAF323 /* Shared */ = { isa = PBXGroup; children = ( + 868B9CCB2E438027001509C3 /* UTMDownloadManager.swift */, + 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */, + 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */, + 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */, + 868B9CB72E437EB4001509C3 /* UTMUpdateManager.swift */, CEF0304C26A2AFBE00667B63 /* BigButtonStyle.swift */, 84B36D2827B790BE00C22685 /* DestructiveButton.swift */, 8471772727CD3CAB00D3A50B /* DetailedSection.swift */, @@ -3645,6 +3689,9 @@ 843BF8302844853E0029D60D /* UTMQemuConfigurationNetwork.swift in Sources */, CE2D927C24AD46670059923A /* UTMProcess.m in Sources */, CEBE820326A4C1B5007AAB12 /* VMWizardDrivesView.swift in Sources */, + 868B9CBF2E437F3A001509C3 /* UTMInstaller.swift in Sources */, + 868B9CC02E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */, + 868B9CC12E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */, 84018689288A44C20050AC51 /* VMWindowState.swift in Sources */, 84258C42288F806400C66366 /* VMToolbarUSBMenuView.swift in Sources */, CE2D928024AD46670059923A /* UTMLegacyQemuConfigurationPortForward.m in Sources */, @@ -3654,6 +3701,7 @@ 848A98C4286F332D006F0550 /* UTMConfiguration.swift in Sources */, 841619AA284315F9000034B2 /* UTMConfigurationInfo.swift in Sources */, CE2D955724AD4F980059923A /* VMConfigDisplayView.swift in Sources */, + 868B9CCC2E438027001509C3 /* UTMDownloadManager.swift in Sources */, CEF0306126A2AFDF00667B63 /* VMWizardOSWindowsView.swift in Sources */, 83A004B926A8CC95001AC09E /* UTMDownloadTask.swift in Sources */, 841E58D52893D01A00137A20 /* UTMApp.swift in Sources */, @@ -3718,6 +3766,7 @@ 84018686288A3B5B0050AC51 /* VMSessionState.swift in Sources */, CE2D957324AD4F990059923A /* VMConfigSharingView.swift in Sources */, CE2D957524AD4F990059923A /* VMConfigInputView.swift in Sources */, + 868B9CBA2E437EB4001509C3 /* UTMUpdateManager.swift in Sources */, CEF0305B26A2AFDF00667B63 /* VMWizardOSOtherView.swift in Sources */, 84C60FB72681A41B00B58C00 /* VMToolbarView.swift in Sources */, CEF01DB72B674BF000725A0F /* UTMPipeInterface.swift in Sources */, @@ -3764,6 +3813,7 @@ CEF0305E26A2AFDF00667B63 /* VMWizardState.swift in Sources */, 843BF82C284482C10029D60D /* UTMQemuConfigurationInput.swift in Sources */, CE2D92FC24AD46670059923A /* VMDisplayMetalViewController+Keyboard.m in Sources */, + 863B2F722E411FD7009CD2BD /* UTMUpdateiOSHandler.swift in Sources */, CE2D957124AD4F990059923A /* UTMExtensions.swift in Sources */, CE020BA324AEDC7C00B44AB6 /* UTMData.swift in Sources */, 848F71EC277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */, @@ -3850,7 +3900,9 @@ CE88A09D2E1DDB4200EAA28E /* UTMASIFImage.m in Sources */, 848A98C2286A2257006F0550 /* UTMAppleConfigurationMacPlatform.swift in Sources */, 84B36D2B27B790BE00C22685 /* DestructiveButton.swift in Sources */, + 868B9CB82E437EB4001509C3 /* UTMUpdateManager.swift in Sources */, CED779E82C79062500EB82AE /* UTMTips.swift in Sources */, + 86DFBFDC2E423923001F5A61 /* MarkdownRenderer.swift in Sources */, CE9B154A2B12A87E003A32DD /* GenerateKey.c in Sources */, CE020BAC24AEE00000B44AB6 /* UTMLoggingSwift.swift in Sources */, 848D99BA28630A780055C215 /* VMConfigSerialView.swift in Sources */, @@ -3878,6 +3930,7 @@ CE88A1682E24E4C000EAA28E /* VMKeyboardMap.m in Sources */, CE2D956A24AD4F990059923A /* VMPlaceholderView.swift in Sources */, CE68E54B2E3C3E0A006B3645 /* VMWizardOSClassicMacView.swift in Sources */, + 863B2F6E2E411832009CD2BD /* UpdateSettingsView.swift in Sources */, CEF0306626A2AFDF00667B63 /* VMWizardOSLinuxView.swift in Sources */, CEE7E938287CFDB100282049 /* UTMLegacyQemuConfiguration+Constants.m in Sources */, CEFE98E129485776007CB7A8 /* UTMScriptingVirtualMachineImpl.swift in Sources */, @@ -3914,6 +3967,9 @@ CE03D0D424DCF6DD00F76B84 /* VMMetalViewInputDelegate.swift in Sources */, 848F71EA277A2A4E006A0240 /* UTMSerialPort.swift in Sources */, CE25124D29C55816000790AB /* UTMScriptingConfigImpl.swift in Sources */, + 868B9CC52E437F3A001509C3 /* UTMInstaller.swift in Sources */, + 868B9CC62E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */, + 868B9CC72E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */, 846F8D582E3850620037162B /* VMKeyboardShortcutsView.swift in Sources */, CE0B6D0224AD56AE00FE012D /* UTMProcess.m in Sources */, CEF0306026A2AFDF00667B63 /* VMWizardState.swift in Sources */, @@ -3962,6 +4018,7 @@ CE2D957024AD4F990059923A /* VMRemovableDrivesView.swift in Sources */, CE25124B29BFE273000790AB /* UTMScriptable.swift in Sources */, CE0B6CFE24AD56AE00FE012D /* UTMLogging.m in Sources */, + 868B9CCD2E438027001509C3 /* UTMDownloadManager.swift in Sources */, 843BF82E284482C10029D60D /* UTMQemuConfigurationInput.swift in Sources */, CE0B6CF824AD568400FE012D /* UTMLegacyViewState.m in Sources */, 84C584E1268E95B3000FCABF /* UTMLegacyAppleConfiguration.swift in Sources */, @@ -3988,6 +4045,7 @@ 841E997B28AA119B003C6CB6 /* UTMRegistryEntry.swift in Sources */, CE2D956424AD4F990059923A /* VMConfigNetworkPortForwardView.swift in Sources */, CE612AC624D3B50700FA6300 /* VMDisplayWindowController.swift in Sources */, + 863B2F702E411BEE009CD2BD /* UpdateAvailableView.swift in Sources */, 53A0BDD726D79FE40010EDC5 /* SavePanel.swift in Sources */, 843BF82A28441FAF0029D60D /* QEMUConstantGenerated.swift in Sources */, 848A98B4286A1215006F0550 /* UTMAppleConfigurationVirtualization.swift in Sources */, @@ -4036,6 +4094,7 @@ 843BF8312844853E0029D60D /* UTMQemuConfigurationNetwork.swift in Sources */, 841E997A28AA119B003C6CB6 /* UTMRegistryEntry.swift in Sources */, CEA45E37263519B5002FA97D /* ContentView.swift in Sources */, + 868B9CBB2E437EB4001509C3 /* UTMUpdateManager.swift in Sources */, CEA45E3A263519B5002FA97D /* UTMLegacyQemuConfiguration+System.m in Sources */, 848D99A9285DB5550055C215 /* VMConfigConstantPicker.swift in Sources */, 841619AF28431952000034B2 /* UTMQemuConfigurationSystem.swift in Sources */, @@ -4085,6 +4144,9 @@ 841619AB284315F9000034B2 /* UTMConfigurationInfo.swift in Sources */, CEA45E6F263519B5002FA97D /* VMConfigInfoView.swift in Sources */, CEA45E72263519B5002FA97D /* VMKeyboardView.m in Sources */, + 868B9CC22E437F3A001509C3 /* UTMInstaller.swift in Sources */, + 868B9CC32E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */, + 868B9CC42E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */, 848D99B5286300160055C215 /* QEMUArgument.swift in Sources */, CEA45E7A263519B5002FA97D /* UTMSpiceIO.m in Sources */, CEF0306B26A2AFDF00667B63 /* VMWizardStartView.swift in Sources */, @@ -4151,6 +4213,7 @@ CEA45ED8263519B5002FA97D /* VMKeyboardButton.m in Sources */, CEF0307526A2B40B00667B63 /* VMWizardHardwareView.swift in Sources */, CE8011212AD4E9E8009001C2 /* UTMApp.swift in Sources */, + 863B2F732E411FD7009CD2BD /* UTMUpdateiOSHandler.swift in Sources */, CE611BEC29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */, CE611BE829F50CAD001817BC /* UTMReleaseHelper.swift in Sources */, CEA45EE8263519B5002FA97D /* VMDisplayMetalViewController+Keyboard.m in Sources */, @@ -4162,6 +4225,7 @@ CEF0304F26A2AFBF00667B63 /* BigButtonStyle.swift in Sources */, 84018691288A73300050AC51 /* VMDisplayViewController.m in Sources */, 847BF9AB2A49C783000BD9AA /* VMData.swift in Sources */, + 868B9CCE2E438027001509C3 /* UTMDownloadManager.swift in Sources */, 84909A8E27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */, CEA45EF4263519B5002FA97D /* VMConfigSoundView.swift in Sources */, 8432329928C3017F00CFBC97 /* GlobalFileImporter.swift in Sources */, @@ -4307,6 +4371,7 @@ CEF7F5F52AEEDCC400E34952 /* QEMUConstant.swift in Sources */, CEF7F5F62AEEDCC400E34952 /* VMConfigPortForwardForm.swift in Sources */, CEF7F5F82AEEDCC400E34952 /* DetailedSection.swift in Sources */, + 863B2F742E411FD7009CD2BD /* UTMUpdateiOSHandler.swift in Sources */, CEF7F5F92AEEDCC400E34952 /* VMToolbarDriveMenuView.swift in Sources */, CE08334B2B784FD400522C03 /* RemoteContentView.swift in Sources */, CEF7F5FA2AEEDCC400E34952 /* VMSettingsView.swift in Sources */, @@ -4315,6 +4380,9 @@ CEF7F5FC2AEEDCC400E34952 /* VMWizardStartView.swift in Sources */, CEF7F5FD2AEEDCC400E34952 /* QEMUConstantGenerated.swift in Sources */, CEF7F5FE2AEEDCC400E34952 /* VMKeyboardButton.m in Sources */, + 868B9CC82E437F3A001509C3 /* UTMInstaller.swift in Sources */, + 868B9CC92E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */, + 868B9CCA2E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */, CEF7F5FF2AEEDCC400E34952 /* UTMDownloadVMTask.swift in Sources */, CEF7F6002AEEDCC400E34952 /* GlobalFileImporter.swift in Sources */, CEF7F6022AEEDCC400E34952 /* VMWizardContent.swift in Sources */, @@ -4326,6 +4394,7 @@ CEF7F6082AEEDCC400E34952 /* VMConfigDisplayConsoleView.swift in Sources */, CEF7F60A2AEEDCC400E34952 /* VMConfigSerialView.swift in Sources */, CE38EC692B5DB3AE008B324B /* UTMRemoteClient.swift in Sources */, + 868B9CCF2E438027001509C3 /* UTMDownloadManager.swift in Sources */, CEF7F60B2AEEDCC400E34952 /* VMWizardState.swift in Sources */, CEF7F60C2AEEDCC400E34952 /* UTMQemuConfigurationInput.swift in Sources */, CEF7F60D2AEEDCC400E34952 /* VMDisplayMetalViewController+Keyboard.m in Sources */, @@ -4340,6 +4409,7 @@ CEF7F6192AEEDCC400E34952 /* VMCardView.swift in Sources */, CEF7F61A2AEEDCC400E34952 /* VMNavigationListView.swift in Sources */, CEF7F61B2AEEDCC400E34952 /* UTMSingleWindowView.swift in Sources */, + 868B9CB92E437EB4001509C3 /* UTMUpdateManager.swift in Sources */, CEF7F61C2AEEDCC400E34952 /* UTMLegacyQemuConfiguration+Sharing.m in Sources */, CEF7F61D2AEEDCC400E34952 /* SizeTextField.swift in Sources */, CEF7F61E2AEEDCC400E34952 /* DefaultTextField.swift in Sources */, From 75772b9a00ede1d594d91b8788333dae181d041c Mon Sep 17 00:00:00 2001 From: Francesco146 Date: Wed, 20 Aug 2025 17:13:09 +0200 Subject: [PATCH 02/10] fix: handle potential error when unmounting DMG --- Platform/Shared/UTMInstaller.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Platform/Shared/UTMInstaller.swift b/Platform/Shared/UTMInstaller.swift index 939123e42..5456e375b 100644 --- a/Platform/Shared/UTMInstaller.swift +++ b/Platform/Shared/UTMInstaller.swift @@ -123,7 +123,7 @@ class UTMInstaller { let mountPoint = try await mountDMG(dmgURL) defer { - try unmountDMG(mountPoint) + try? unmountDMG(mountPoint) } let appURL = try findAppBundle(in: mountPoint) From f2284eefaf0a8e5314e8e7bddfe1d90ff0023598 Mon Sep 17 00:00:00 2001 From: Francesco146 Date: Wed, 20 Aug 2025 18:18:37 +0200 Subject: [PATCH 03/10] refactor: remove UTMUpdateErrorHandler and UTMUpdateSecurity classes (not needed) --- Platform/Shared/UTMUpdateErrorHandler.swift | 193 -------------------- Platform/Shared/UTMUpdateSecurity.swift | 145 --------------- UTM.xcodeproj/project.pbxproj | 20 -- 3 files changed, 358 deletions(-) delete mode 100644 Platform/Shared/UTMUpdateErrorHandler.swift delete mode 100644 Platform/Shared/UTMUpdateSecurity.swift diff --git a/Platform/Shared/UTMUpdateErrorHandler.swift b/Platform/Shared/UTMUpdateErrorHandler.swift deleted file mode 100644 index c5786b20f..000000000 --- a/Platform/Shared/UTMUpdateErrorHandler.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// Copyright © 2024 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 Foundation -import OSLog - -extension Logger { - static let updateManager = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UpdateManager") - static let updateDownload = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UpdateDownload") - static let updateInstaller = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UpdateInstaller") - static let updateSecurity = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UpdateSecurity") -} - -class UTMUpdateErrorHandler { - - enum ErrorRecoveryAction { - case retry - case skipVersion - case manualUpdate - case reportBug - case none - } - - static func handleError(_ error: Error, context: String) -> ErrorRecoveryAction { - Logger.updateManager.error("\(context): \(error.localizedDescription)") - - switch error { - case let updateError as UTMUpdateManager.UpdateError: - return handleUpdateError(updateError, context: context) - case let urlError as URLError: - return handleNetworkError(urlError, context: context) - case let securityError as UTMUpdateSecurity.SecurityError: - return handleSecurityError(securityError, context: context) - default: - Logger.updateManager.error("Unhandled error type: \(type(of: error))") - return .reportBug - } - } - - private static func handleUpdateError(_ error: UTMUpdateManager.UpdateError, context: String) -> ErrorRecoveryAction { - switch error { - case .networkUnavailable: - Logger.updateManager.info("Network unavailable, user can retry later") - return .retry - - case .downloadFailed: - Logger.updateManager.warning("Download failed, user can retry or skip") - return .retry - - case .verificationFailed: - Logger.updateManager.error("Security verification failed - potential security issue") - return .reportBug - - case .installationFailed: - Logger.updateManager.error("Installation failed, suggest manual update") - return .manualUpdate - - case .insufficientSpace: - Logger.updateManager.warning("Insufficient disk space") - return .none - - case .unsupportedVersion: - Logger.updateManager.info("Update requires newer system version") - return .skipVersion - - case .invalidResponse: - Logger.updateManager.error("Invalid server response") - return .retry - - case .noUpdateAvailable: - Logger.updateManager.info("No update available") - return .none - } - } - - private static func handleNetworkError(_ error: URLError, context: String) -> ErrorRecoveryAction { - switch error.code { - case .notConnectedToInternet, .networkConnectionLost: - Logger.updateManager.info("Network connectivity issue") - return .retry - - case .timedOut: - Logger.updateManager.warning("Network timeout") - return .retry - - case .cannotFindHost, .cannotConnectToHost: - Logger.updateManager.error("Cannot reach update server") - return .retry - - case .serverCertificateUntrusted, .clientCertificateRequired: - Logger.updateManager.error("Certificate/security issue") - return .reportBug - - default: - Logger.updateManager.error("Network error: \(error.localizedDescription)") - return .retry - } - } - - private static func handleSecurityError(_ error: UTMUpdateSecurity.SecurityError, context: String) -> ErrorRecoveryAction { - switch error { - case .untrustedHost, .invalidCertificate, .invalidSignature: - Logger.updateSecurity.error("Security validation failed: \(error.localizedDescription)") - return .reportBug - - case .checksumMismatch: - Logger.updateSecurity.error("File integrity check failed") - return .retry - - case .maliciousContent: - Logger.updateSecurity.critical("Potential malicious content detected") - return .reportBug - } - } - - static func createUserFriendlyMessage(for error: Error, recoveryAction: ErrorRecoveryAction) -> (title: String, message: String, buttonText: String) { - switch recoveryAction { - case .retry: - return ( - title: "Update Failed", - message: "The update could not be completed. This is usually a temporary issue with your network connection.", - buttonText: "Try Again" - ) - - case .skipVersion: - return ( - title: "Update Not Compatible", - message: "This update requires a newer version of your operating system. You can skip this version and wait for the next update.", - buttonText: "Skip Version" - ) - - case .manualUpdate: - return ( - title: "Installation Failed", - message: "The automatic installation failed. Please download and install the update manually from the UTM website.", - buttonText: "Download Manually" - ) - - case .reportBug: - return ( - title: "Update Error", - message: "An unexpected error occurred. Please report this issue to help us improve UTM.", - buttonText: "Report Issue" - ) - - case .none: - return ( - title: "Update Issue", - message: error.localizedDescription, - buttonText: "OK" - ) - } - } - - static func logSystemInfo() { - Logger.updateManager.info("System Info - OS: \(ProcessInfo.processInfo.operatingSystemVersionString)") - Logger.updateManager.info("System Info - App Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")") - Logger.updateManager.info("System Info - Build: \(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown")") - - #if os(macOS) - Logger.updateManager.info("System Info - Architecture: \(ProcessInfo.processInfo.processorCount) cores") - #endif - - // Log available disk space - #if os(macOS) - let homeURL = FileManager.default.homeDirectoryForCurrentUser - #else - let homeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSTemporaryDirectory()) - #endif - do { - let attributes = try FileManager.default.attributesOfFileSystem(forPath: homeURL.path) - if let freeSize = attributes[.systemFreeSize] as? NSNumber { - let freeGB = freeSize.doubleValue / (1024 * 1024 * 1024) - Logger.updateManager.info("System Info - Free Space: \(String(format: "%.2f", freeGB)) GB") - } - } catch { - Logger.updateManager.warning("Could not determine free space: \(error.localizedDescription)") - } - } -} diff --git a/Platform/Shared/UTMUpdateSecurity.swift b/Platform/Shared/UTMUpdateSecurity.swift deleted file mode 100644 index e2223e281..000000000 --- a/Platform/Shared/UTMUpdateSecurity.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// Copyright © 2024 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 Foundation -import Security -import CryptoKit - -class UTMUpdateSecurity { - private static let trustedHosts = [ - "api.github.com", - "github.com", - "objects.githubusercontent.com" - ] - - enum SecurityError: Error, LocalizedError { - case untrustedHost - case invalidCertificate - case checksumMismatch - case invalidSignature - case maliciousContent - - var errorDescription: String? { - switch self { - case .untrustedHost: - return "Download from untrusted host blocked" - case .invalidCertificate: - return "Invalid or untrusted certificate" - case .checksumMismatch: - return "File integrity check failed" - case .invalidSignature: - return "Invalid digital signature" - case .maliciousContent: - return "Potentially malicious content detected" - } - } - } - - static func validateDownloadURL(_ url: URL) throws { - guard let host = url.host, trustedHosts.contains(host) else { - throw SecurityError.untrustedHost - } - - guard url.scheme == "https" else { - throw SecurityError.untrustedHost - } - } - - static func validateCertificate(for host: String, certificate: SecCertificate) throws { - let certificateData = SecCertificateCopyData(certificate) - let data = CFDataGetBytePtr(certificateData) - let length = CFDataGetLength(certificateData) - - let certBytes = Data(bytes: data!, count: length) - let fingerprint = SHA256.hash(data: certBytes) - let fingerprintString = fingerprint.compactMap { String(format: "%02x", $0) }.joined() - - // TODO: this is a simplified check - need more robust validation for example, - // checking against a list of known good fingerprints - logger.info("Certificate fingerprint for \(host): \(fingerprintString)") - } - - static func validateFileIntegrity(at url: URL, expectedSize: Int64, expectedChecksum: String? = nil) throws { - let attributes = try FileManager.default.attributesOfItem(atPath: url.path) - let actualSize = attributes[.size] as? Int64 ?? 0 - - guard actualSize == expectedSize else { - throw SecurityError.checksumMismatch - } - - if let expectedChecksum = expectedChecksum { - let actualChecksum = try calculateChecksum(for: url) - guard actualChecksum.lowercased() == expectedChecksum.lowercased() else { - throw SecurityError.checksumMismatch - } - } - } - - private static func calculateChecksum(for url: URL) throws -> String { - let data = try Data(contentsOf: url) - let hash = SHA256.hash(data: data) - return hash.compactMap { String(format: "%02x", $0) }.joined() - } - - static func validateAppBundle(at url: URL) throws { - let bundleURL = url.appendingPathComponent("Contents") - - let requiredPaths = [ - "Info.plist", - "MacOS", - "_CodeSignature" - ] - - for path in requiredPaths { - let fullPath = bundleURL.appendingPathComponent(path) - guard FileManager.default.fileExists(atPath: fullPath.path) else { - throw SecurityError.invalidSignature - } - } - - try validateInfoPlist(at: bundleURL.appendingPathComponent("Info.plist")) - } - - private static func validateInfoPlist(at url: URL) throws { - guard let plistData = NSDictionary(contentsOf: url), - let bundleId = plistData["CFBundleIdentifier"] as? String else { - throw SecurityError.invalidSignature - } - - let validBundleIds = ["com.utmapp.UTM", "com.utmapp.UTM.SE", "com.utmapp.UTM.Remote"] - guard validBundleIds.contains(bundleId) else { - throw SecurityError.maliciousContent - } - } - - // URLSession delegate method for certificate pinning - static func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - - guard let serverTrust = challenge.protectionSpace.serverTrust, - let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else { - completionHandler(.cancelAuthenticationChallenge, nil) - return - } - - do { - try validateCertificate(for: challenge.protectionSpace.host, certificate: certificate) - completionHandler(.useCredential, URLCredential(trust: serverTrust)) - } catch { - logger.error("Certificate validation failed: \(error.localizedDescription)") - completionHandler(.cancelAuthenticationChallenge, nil) - } - } -} diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 6050a5121..bf1e8c03d 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -290,17 +290,9 @@ 868B9CBA2E437EB4001509C3 /* UTMUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CB72E437EB4001509C3 /* UTMUpdateManager.swift */; }; 868B9CBB2E437EB4001509C3 /* UTMUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CB72E437EB4001509C3 /* UTMUpdateManager.swift */; }; 868B9CBF2E437F3A001509C3 /* UTMInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */; }; - 868B9CC02E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */; }; - 868B9CC12E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */; }; 868B9CC22E437F3A001509C3 /* UTMInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */; }; - 868B9CC32E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */; }; - 868B9CC42E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */; }; 868B9CC52E437F3A001509C3 /* UTMInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */; }; - 868B9CC62E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */; }; - 868B9CC72E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */; }; 868B9CC82E437F3A001509C3 /* UTMInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */; }; - 868B9CC92E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */; }; - 868B9CCA2E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */; }; 868B9CCC2E438027001509C3 /* UTMDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CCB2E438027001509C3 /* UTMDownloadManager.swift */; }; 868B9CCD2E438027001509C3 /* UTMDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CCB2E438027001509C3 /* UTMDownloadManager.swift */; }; 868B9CCE2E438027001509C3 /* UTMDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868B9CCB2E438027001509C3 /* UTMDownloadManager.swift */; }; @@ -1844,8 +1836,6 @@ 863B2F712E411FD7009CD2BD /* UTMUpdateiOSHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMUpdateiOSHandler.swift; sourceTree = ""; }; 868B9CB72E437EB4001509C3 /* UTMUpdateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMUpdateManager.swift; sourceTree = ""; }; 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMInstaller.swift; sourceTree = ""; }; - 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMUpdateErrorHandler.swift; sourceTree = ""; }; - 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMUpdateSecurity.swift; sourceTree = ""; }; 868B9CCB2E438027001509C3 /* UTMDownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDownloadManager.swift; sourceTree = ""; }; 86DFBFDB2E42391E001F5A61 /* MarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownRenderer.swift; sourceTree = ""; }; 9786BB59294056960032B858 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -3057,8 +3047,6 @@ children = ( 868B9CCB2E438027001509C3 /* UTMDownloadManager.swift */, 868B9CBC2E437F3A001509C3 /* UTMInstaller.swift */, - 868B9CBD2E437F3A001509C3 /* UTMUpdateErrorHandler.swift */, - 868B9CBE2E437F3A001509C3 /* UTMUpdateSecurity.swift */, 868B9CB72E437EB4001509C3 /* UTMUpdateManager.swift */, CEF0304C26A2AFBE00667B63 /* BigButtonStyle.swift */, 84B36D2827B790BE00C22685 /* DestructiveButton.swift */, @@ -3690,8 +3678,6 @@ CE2D927C24AD46670059923A /* UTMProcess.m in Sources */, CEBE820326A4C1B5007AAB12 /* VMWizardDrivesView.swift in Sources */, 868B9CBF2E437F3A001509C3 /* UTMInstaller.swift in Sources */, - 868B9CC02E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */, - 868B9CC12E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */, 84018689288A44C20050AC51 /* VMWindowState.swift in Sources */, 84258C42288F806400C66366 /* VMToolbarUSBMenuView.swift in Sources */, CE2D928024AD46670059923A /* UTMLegacyQemuConfigurationPortForward.m in Sources */, @@ -3968,8 +3954,6 @@ 848F71EA277A2A4E006A0240 /* UTMSerialPort.swift in Sources */, CE25124D29C55816000790AB /* UTMScriptingConfigImpl.swift in Sources */, 868B9CC52E437F3A001509C3 /* UTMInstaller.swift in Sources */, - 868B9CC62E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */, - 868B9CC72E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */, 846F8D582E3850620037162B /* VMKeyboardShortcutsView.swift in Sources */, CE0B6D0224AD56AE00FE012D /* UTMProcess.m in Sources */, CEF0306026A2AFDF00667B63 /* VMWizardState.swift in Sources */, @@ -4145,8 +4129,6 @@ CEA45E6F263519B5002FA97D /* VMConfigInfoView.swift in Sources */, CEA45E72263519B5002FA97D /* VMKeyboardView.m in Sources */, 868B9CC22E437F3A001509C3 /* UTMInstaller.swift in Sources */, - 868B9CC32E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */, - 868B9CC42E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */, 848D99B5286300160055C215 /* QEMUArgument.swift in Sources */, CEA45E7A263519B5002FA97D /* UTMSpiceIO.m in Sources */, CEF0306B26A2AFDF00667B63 /* VMWizardStartView.swift in Sources */, @@ -4381,8 +4363,6 @@ CEF7F5FD2AEEDCC400E34952 /* QEMUConstantGenerated.swift in Sources */, CEF7F5FE2AEEDCC400E34952 /* VMKeyboardButton.m in Sources */, 868B9CC82E437F3A001509C3 /* UTMInstaller.swift in Sources */, - 868B9CC92E437F3A001509C3 /* UTMUpdateErrorHandler.swift in Sources */, - 868B9CCA2E437F3A001509C3 /* UTMUpdateSecurity.swift in Sources */, CEF7F5FF2AEEDCC400E34952 /* UTMDownloadVMTask.swift in Sources */, CEF7F6002AEEDCC400E34952 /* GlobalFileImporter.swift in Sources */, CEF7F6022AEEDCC400E34952 /* VMWizardContent.swift in Sources */, From dc57f0b303fb5f7a11c8ecfb73ad60282b6c9e31 Mon Sep 17 00:00:00 2001 From: Francesco146 Date: Wed, 20 Aug 2025 18:48:28 +0200 Subject: [PATCH 04/10] refactor: update installation process to show guidance for DMG setup --- Platform/Shared/UTMInstaller.swift | 215 ++++------------------- Platform/Shared/UTMUpdateManager.swift | 13 +- Platform/macOS/UpdateAvailableView.swift | 13 +- Platform/macOS/UpdateSettingsView.swift | 6 +- 4 files changed, 51 insertions(+), 196 deletions(-) diff --git a/Platform/Shared/UTMInstaller.swift b/Platform/Shared/UTMInstaller.swift index 5456e375b..83fb6f320 100644 --- a/Platform/Shared/UTMInstaller.swift +++ b/Platform/Shared/UTMInstaller.swift @@ -29,6 +29,7 @@ class UTMInstaller { case backupFailed case platformNotSupported case insufficientDiskSpace + case mountFailed var errorDescription: String? { switch self { @@ -44,13 +45,15 @@ class UTMInstaller { return "Platform not supported for automatic updates" case .insufficientDiskSpace: return "Insufficient disk space for installation" + case .mountFailed: + return "Failed to mount DMG file" } } } func installUpdate(from downloadURL: URL) async throws { #if os(macOS) - try await installMacOSUpdate(from: downloadURL) + try await openDMGAndShowInstructions(from: downloadURL) #elseif os(iOS) try await installiOSUpdate(from: downloadURL) #else @@ -59,109 +62,66 @@ class UTMInstaller { } #if os(macOS) - private func installMacOSUpdate(from downloadURL: URL) async throws { - try validateDownloadedFile(at: downloadURL) + private func openDMGAndShowInstructions(from downloadURL: URL) async throws { + // Mount the DMG + let mountPoint = try await mountDMG(downloadURL) - try checkDiskSpace(for: downloadURL) + // Show the mounted volume in Finder + try showMountedDMGInFinder(mountPoint) - let backupURL = try createBackup() - - do { - if downloadURL.pathExtension.lowercased() == "dmg" { - try await installFromDMG(downloadURL) - } else { - throw InstallationError.invalidBundle - } - - try restartApplication() - - } catch { - // Rollback on failure - try? rollbackFromBackup(backupURL) - throw InstallationError.installationFailed(error.localizedDescription) - } + // Show installation instructions to the user + try showInstallationInstructions() } - private func validateDownloadedFile(at url: URL) throws { - guard FileManager.default.fileExists(atPath: url.path) else { - throw InstallationError.invalidBundle - } - - // additional validation could be added here: - // - code signature verification - // - bundle structure validation - // - checksum verification + private func showMountedDMGInFinder(_ mountPoint: URL) throws { + NSWorkspace.shared.open(mountPoint) } - private func checkDiskSpace(for downloadURL: URL) throws { - let fileAttributes = try FileManager.default.attributesOfItem(atPath: downloadURL.path) - let fileSize = fileAttributes[.size] as? Int64 ?? 0 - - // estimate required space (file size + extraction space + backup space) - let requiredSpace = fileSize * 3 - - if let availableSpace = try? FileManager.default.url(for: .applicationDirectory, in: .localDomainMask, appropriateFor: nil, create: false).resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]).volumeAvailableCapacityForImportantUsage { - if availableSpace < requiredSpace { - throw InstallationError.insufficientDiskSpace + private func showInstallationInstructions() throws { + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = NSLocalizedString("Installation Instructions", comment: "UTMInstaller") + alert.informativeText = NSLocalizedString("Please follow these steps to complete the update:\n\n1. Save any unsaved work in your virtual machines\n2. Quit UTM completely\n3. Drag the new UTM.app from the opened DMG to your Applications folder (replace the existing version)\n4. Restart UTM\n\nThe DMG will remain mounted until you manually eject it.", comment: "UTMInstaller") + alert.addButton(withTitle: NSLocalizedString("OK", comment: "UTMInstaller")) + alert.addButton(withTitle: NSLocalizedString("Quit UTM Now", comment: "UTMInstaller")) + alert.alertStyle = .informational + + let response = alert.runModal() + + if response == .alertSecondButtonReturn { + // User chose to quit UTM now + NSApplication.shared.terminate(nil) } } } - private func createBackup() throws -> URL { - let currentAppURL = Bundle.main.bundleURL - - let backupDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("UTMBackup-\(UUID().uuidString)") - let backupURL = backupDirectory.appendingPathComponent(currentAppURL.lastPathComponent) - - try FileManager.default.createDirectory(at: backupDirectory, withIntermediateDirectories: true) - try FileManager.default.copyItem(at: currentAppURL, to: backupURL) - - return backupURL - } - - private func installFromDMG(_ dmgURL: URL) async throws { - let mountPoint = try await mountDMG(dmgURL) - - defer { - try? unmountDMG(mountPoint) - } - - let appURL = try findAppBundle(in: mountPoint) - - try installAppBundle(from: appURL) - } - - private func installFromZIP(_ zipURL: URL) async throws { - let extractionURL = try await extractZIP(zipURL) - - defer { - try? FileManager.default.removeItem(at: extractionURL) - } - - let appURL = try findAppBundle(in: extractionURL) - - try installAppBundle(from: appURL) - } private func mountDMG(_ dmgURL: URL) async throws -> URL { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") process.arguments = ["attach", dmgURL.path, "-nobrowse", "-quiet", "-plist"] + + print("process.arguments: \(process.arguments ?? [""])") + print("process.environment: \(process.environment ?? [:])") + print("Mounting DMG at: \(dmgURL.path)") + let pipe = Pipe() process.standardOutput = pipe try process.run() process.waitUntilExit() + + print("process.terminationStatus: \(process.terminationStatus)") guard process.terminationStatus == 0 else { - throw InstallationError.installationFailed("Failed to mount DMG") + throw InstallationError.mountFailed } let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], let systemEntities = plist["system-entities"] as? [[String: Any]] else { - throw InstallationError.installationFailed("Failed to parse mount output") + throw InstallationError.mountFailed } for entity in systemEntities { @@ -170,108 +130,7 @@ class UTMInstaller { } } - throw InstallationError.installationFailed("No mount point found") - } - - private func unmountDMG(_ mountPoint: URL) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") - process.arguments = ["detach", mountPoint.path, "-quiet"] - - try process.run() - process.waitUntilExit() - } - - private func extractZIP(_ zipURL: URL) async throws -> URL { - let extractionURL = FileManager.default.temporaryDirectory.appendingPathComponent("UTMExtraction-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: extractionURL, withIntermediateDirectories: true) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") - process.arguments = ["-q", zipURL.path, "-d", extractionURL.path] - - try process.run() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - throw InstallationError.installationFailed("Failed to extract ZIP") - } - - return extractionURL - } - - private func findAppBundle(in directory: URL) throws -> URL { - let contents = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) - - for item in contents { - if item.pathExtension == "app" { - return item - } - - // look in subdirectories - let resourceValues = try item.resourceValues(forKeys: [.isDirectoryKey]) - if resourceValues.isDirectory == true { - if let appURL = try? findAppBundle(in: item) { - return appURL - } - } - } - - throw InstallationError.invalidBundle - } - - private func installAppBundle(from sourceURL: URL) throws { - let currentAppURL = Bundle.main.bundleURL - - try FileManager.default.removeItem(at: currentAppURL) - - try FileManager.default.copyItem(at: sourceURL, to: currentAppURL) - - try setExecutablePermissions(for: currentAppURL) - } - - private func setExecutablePermissions(for appURL: URL) throws { - let executableURL = appURL.appendingPathComponent("Contents/MacOS").appendingPathComponent(appURL.deletingPathExtension().lastPathComponent) - - let attributes: [FileAttributeKey: Any] = [ - .posixPermissions: 0o755 - ] - - try FileManager.default.setAttributes(attributes, ofItemAtPath: executableURL.path) - } - - private func rollbackFromBackup(_ backupURL: URL) throws { - let currentAppURL = Bundle.main.bundleURL - - try? FileManager.default.removeItem(at: currentAppURL) - - try FileManager.default.copyItem(at: backupURL, to: currentAppURL) - } - - private func restartApplication() throws { - let appURL = Bundle.main.bundleURL - - // create a script to restart the app after a delay - let script = """ - #!/bin/bash - sleep 2 - open "\(appURL.path)" - """ - - let scriptURL = FileManager.default.temporaryDirectory.appendingPathComponent("restart_utm.sh") - try script.write(to: scriptURL, atomically: true, encoding: .utf8) - - // make script executable - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) - - // execute script - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/bash") - process.arguments = [scriptURL.path] - process.launch() - - // exit current app - NSApp.terminate(nil) + throw InstallationError.mountFailed } #endif diff --git a/Platform/Shared/UTMUpdateManager.swift b/Platform/Shared/UTMUpdateManager.swift index 6c879a1a6..16938a013 100644 --- a/Platform/Shared/UTMUpdateManager.swift +++ b/Platform/Shared/UTMUpdateManager.swift @@ -439,28 +439,27 @@ class UTMUpdateManager: UTMReleaseHelper { let installer = UTMInstaller() try await installer.installUpdate(from: url) - // Installation successful - await handleInstallationSuccess() + // Installation guidance shown - reset state but don't restart + await handleInstallationGuidanceShown() } catch { updateError = error as? UpdateError ?? .installationFailed(error.localizedDescription) isInstalling = false } } - private func handleInstallationSuccess() async { + private func handleInstallationGuidanceShown() async { // Clean up downloaded file if let downloadManager = downloadManager { downloadManager.cleanupDownload() } - // Reset state + // Reset downloading/installing state, but keep update info visible isDownloading = false isInstalling = false - isUpdateAvailable = false - updateInfo = nil downloadProgress = 0.0 - // App will restart as part of installation process + // Keep isUpdateAvailable true and updateInfo to allow user to try again if needed + // The user will manually dismiss this when they complete the installation } // MARK: - Public Interface diff --git a/Platform/macOS/UpdateAvailableView.swift b/Platform/macOS/UpdateAvailableView.swift index 17122a8ec..34ca4baa7 100644 --- a/Platform/macOS/UpdateAvailableView.swift +++ b/Platform/macOS/UpdateAvailableView.swift @@ -94,10 +94,6 @@ struct UpdateAvailableView: View { Text(NSLocalizedString("Installing Update...", comment: "UpdateAvailableView")) .font(.headline) - - Text(NSLocalizedString("UTM will restart when installation is complete", comment: "UpdateAvailableView")) - .font(.caption) - .foregroundColor(.secondary) } .padding() .background(Color.secondary.opacity(0.1)) @@ -143,7 +139,7 @@ struct UpdateAvailableView: View { } else if !updateManager.isInstalling { if #available(macOS 12.0, *) { - Button(NSLocalizedString("Download & Install", comment: "UpdateAvailableView")) { + Button(NSLocalizedString("Download & Open DMG", comment: "UpdateAvailableView")) { Task { await updateManager.downloadAndInstall() } @@ -152,7 +148,7 @@ struct UpdateAvailableView: View { .disabled(updateManager.isDownloading || updateManager.isInstalling) } else { - Button(NSLocalizedString("Download & Install", comment: "UpdateAvailableView")) { + Button(NSLocalizedString("Download & Open DMG", comment: "UpdateAvailableView")) { Task { await updateManager.downloadAndInstall() } @@ -196,16 +192,17 @@ struct UpdateProgressView: View { } else if updateManager.isInstalling { VStack(spacing: 8) { - Text(NSLocalizedString("Installing Update", comment: "UpdateAvailableView")) + Text(NSLocalizedString("Preparing Installation", comment: "UpdateAvailableView")) .font(.headline) ProgressView() .progressViewStyle(CircularProgressViewStyle()) - Text(NSLocalizedString("UTM will restart when installation is complete", comment: "UpdateAvailableView")) + Text(NSLocalizedString("Opening DMG and showing installation instructions", comment: "UpdateAvailableView")) .font(.caption) .foregroundColor(.secondary) + .multilineTextAlignment(.center) } } } diff --git a/Platform/macOS/UpdateSettingsView.swift b/Platform/macOS/UpdateSettingsView.swift index 616b90f0a..a63e1fd3f 100644 --- a/Platform/macOS/UpdateSettingsView.swift +++ b/Platform/macOS/UpdateSettingsView.swift @@ -217,7 +217,7 @@ struct UpdateAvailableSection: View { ProgressView() .scaleEffect(0.5) - Text(NSLocalizedString("Installing...", comment: "UpdateSettingsView")) + Text(NSLocalizedString("Preparing installation...", comment: "UpdateSettingsView")) .font(.caption) } } @@ -243,7 +243,7 @@ struct UpdateAvailableSection: View { } else if !updateManager.isInstalling { if #available(macOS 12.0, *) { - Button(NSLocalizedString("Download & Install", comment: "UpdateSettingsView")) { + Button(NSLocalizedString("Download & Open DMG", comment: "UpdateSettingsView")) { Task { await updateManager.downloadAndInstall() } @@ -251,7 +251,7 @@ struct UpdateAvailableSection: View { .buttonStyle(.borderedProminent) } else { - Button(NSLocalizedString("Download & Install", comment: "UpdateSettingsView")) { + Button(NSLocalizedString("Download & Open DMG", comment: "UpdateSettingsView")) { Task { await updateManager.downloadAndInstall() } From 7de60374e7df7be62f9af19fb6580e5c040f9937 Mon Sep 17 00:00:00 2001 From: Francesco146 Date: Wed, 20 Aug 2025 20:19:15 +0200 Subject: [PATCH 05/10] i18n: update Italian --- Platform/it.lproj/Localizable.strings | 60 ++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/Platform/it.lproj/Localizable.strings b/Platform/it.lproj/Localizable.strings index 8ab9dea79..12fd8524d 100644 --- a/Platform/it.lproj/Localizable.strings +++ b/Platform/it.lproj/Localizable.strings @@ -2145,7 +2145,7 @@ "Cancel Download" = "Annulla Download"; /* UpdateAvailableView */ -"Download & Install" = "Scarica e Installa"; +"Download" = "Scarica"; /* UpdateAvailableView */ "Downloading UTM %@" = "Scaricamento UTM %@"; @@ -2272,6 +2272,62 @@ /* UTMUpdateiOSHandler */ "Later" = "Più Tardi"; +/* UTMUpdateManager */ +"Stable" = "Stabile"; + +/* UTMUpdateManager */ +"Beta" = "Beta"; + +/* UTMUpdateManager */ +"All Releases" = "Tutte le Versioni"; + +/* UTMUpdateManager */ +"Network connection unavailable" = "Connessione di rete non disponibile"; + +/* UTMUpdateManager */ +"Download failed: %@" = "Download fallito: %@"; + +/* UTMUpdateManager */ +"Update verification failed" = "Verifica dell'aggiornamento fallita"; + +/* UTMUpdateManager */ +"Installation failed: %@" = "Installazione fallita: %@"; + +/* UTMUpdateManager */ +"Insufficient disk space for update" = "Spazio su disco insufficiente per l'aggiornamento"; + +/* UTMUpdateManager */ +"This update requires a newer system version" = "Questo aggiornamento richiede una versione di sistema più recente"; + +/* UTMUpdateManager */ +"Invalid response from update server" = "Risposta non valida dal server di aggiornamento"; + +/* UTMUpdateManager */ +"No update available" = "Nessun aggiornamento disponibile"; + +/* UTMInstaller */ +"Installation not authorized" = "Installazione non autorizzata"; + +/* UTMInstaller */ +"Invalid application bundle" = "Bundle dell'applicazione non valido"; + +/* UTMInstaller */ +"Failed to create backup" = "Impossibile creare il backup"; + +/* UTMInstaller */ +"Platform not supported for automatic updates" = "Piattaforma non supportata per gli aggiornamenti automatici"; + +/* UTMInstaller */ +"Insufficient disk space for installation" = "Spazio su disco insufficiente per l'installazione"; + +/* UTMInstaller */ +"Failed to mount DMG file" = "Impossibile montare il file DMG"; + +/* UTMInstaller */ +"Cannot construct App Store URL" = "Impossibile costruire l'URL dell'App Store"; + +/* UTMInstaller */ +"Cannot open TestFlight" = "Impossibile aprire TestFlight"; /* UpdateSettingsView/UpdateAvailableView */ -"Released %@" = "Rilasciato %@"; +"Released: %@" = "Rilasciato %@"; From 1ea665a930a0c79509312d95f55d01d60cc74290 Mon Sep 17 00:00:00 2001 From: Francesco146 Date: Wed, 20 Aug 2025 20:20:09 +0200 Subject: [PATCH 06/10] refactor: use localized strings --- Platform/Shared/UTMUpdateManager.swift | 22 +++++++++++----------- Platform/macOS/UpdateAvailableView.swift | 4 ++-- Platform/macOS/UpdateSettingsView.swift | 6 +++--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Platform/Shared/UTMUpdateManager.swift b/Platform/Shared/UTMUpdateManager.swift index 16938a013..008078c36 100644 --- a/Platform/Shared/UTMUpdateManager.swift +++ b/Platform/Shared/UTMUpdateManager.swift @@ -95,9 +95,9 @@ class UTMUpdateManager: UTMReleaseHelper { var displayName: String { switch self { - case .stable: return "Stable" - case .beta: return "Beta" - case .all: return "All Releases" + case .stable: return NSLocalizedString("Stable", comment: "UTMUpdateManager") + case .beta: return NSLocalizedString("Beta", comment: "UTMUpdateManager") + case .all: return NSLocalizedString("All Releases", comment: "UTMUpdateManager") } } } @@ -115,21 +115,21 @@ class UTMUpdateManager: UTMReleaseHelper { var errorDescription: String? { switch self { case .networkUnavailable: - return "Network connection unavailable" + return NSLocalizedString("Network connection unavailable", comment: "UTMUpdateManager") case .downloadFailed(let reason): - return "Download failed: \(reason)" + return String.localizedStringWithFormat(NSLocalizedString("Download failed: %@", comment: "UTMUpdateManager"), reason) case .verificationFailed: - return "Update verification failed" + return NSLocalizedString("Update verification failed", comment: "UTMUpdateManager") case .installationFailed(let reason): - return "Installation failed: \(reason)" + return String.localizedStringWithFormat(NSLocalizedString("Installation failed: %@", comment: "UTMUpdateManager"), reason) case .insufficientSpace: - return "Insufficient disk space for update" + return NSLocalizedString("Insufficient disk space for update", comment: "UTMUpdateManager") case .unsupportedVersion: - return "This update requires a newer system version" + return NSLocalizedString("This update requires a newer system version", comment: "UTMUpdateManager") case .invalidResponse: - return "Invalid response from update server" + return NSLocalizedString("Invalid response from update server", comment: "UTMUpdateManager") case .noUpdateAvailable: - return "No update available" + return NSLocalizedString("No update available", comment: "UTMUpdateManager") } } } diff --git a/Platform/macOS/UpdateAvailableView.swift b/Platform/macOS/UpdateAvailableView.swift index 34ca4baa7..fff19a40e 100644 --- a/Platform/macOS/UpdateAvailableView.swift +++ b/Platform/macOS/UpdateAvailableView.swift @@ -139,7 +139,7 @@ struct UpdateAvailableView: View { } else if !updateManager.isInstalling { if #available(macOS 12.0, *) { - Button(NSLocalizedString("Download & Open DMG", comment: "UpdateAvailableView")) { + Button(NSLocalizedString("Download", comment: "UpdateAvailableView")) { Task { await updateManager.downloadAndInstall() } @@ -148,7 +148,7 @@ struct UpdateAvailableView: View { .disabled(updateManager.isDownloading || updateManager.isInstalling) } else { - Button(NSLocalizedString("Download & Open DMG", comment: "UpdateAvailableView")) { + Button(NSLocalizedString("Download", comment: "UpdateAvailableView")) { Task { await updateManager.downloadAndInstall() } diff --git a/Platform/macOS/UpdateSettingsView.swift b/Platform/macOS/UpdateSettingsView.swift index a63e1fd3f..ded3d42a5 100644 --- a/Platform/macOS/UpdateSettingsView.swift +++ b/Platform/macOS/UpdateSettingsView.swift @@ -243,7 +243,7 @@ struct UpdateAvailableSection: View { } else if !updateManager.isInstalling { if #available(macOS 12.0, *) { - Button(NSLocalizedString("Download & Open DMG", comment: "UpdateSettingsView")) { + Button(NSLocalizedString("Download", comment: "UpdateSettingsView")) { Task { await updateManager.downloadAndInstall() } @@ -251,7 +251,7 @@ struct UpdateAvailableSection: View { .buttonStyle(.borderedProminent) } else { - Button(NSLocalizedString("Download & Open DMG", comment: "UpdateSettingsView")) { + Button(NSLocalizedString("Download", comment: "UpdateSettingsView")) { Task { await updateManager.downloadAndInstall() } @@ -315,7 +315,7 @@ struct UpdateReleaseNotesView: View { .fontWeight(.semibold) HStack { - Text(String.localizedStringWithFormat(NSLocalizedString("Released %@", comment: "UpdateSettingsView"), updateInfo.releaseDate.abbreviatedDateString)) + Text(String.localizedStringWithFormat(NSLocalizedString("Released: %@", comment: "UpdateSettingsView"), updateInfo.releaseDate.abbreviatedDateString)) .font(.subheadline) .foregroundColor(.secondary) From 961de415d4990a51bcc08c16f6ac8c2cf6393cc2 Mon Sep 17 00:00:00 2001 From: Francesco146 Date: Wed, 20 Aug 2025 20:20:37 +0200 Subject: [PATCH 07/10] fix: update destination file extension to .dmg for downloaded updates --- Platform/Shared/UTMDownloadManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Platform/Shared/UTMDownloadManager.swift b/Platform/Shared/UTMDownloadManager.swift index d9e43ee03..ff579fafd 100644 --- a/Platform/Shared/UTMDownloadManager.swift +++ b/Platform/Shared/UTMDownloadManager.swift @@ -148,7 +148,7 @@ extension UTMDownloadManager: URLSessionDownloadDelegate { nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { // Move file to a permanent location let documentsPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - let destinationURL = documentsPath.appendingPathComponent("UTMUpdate-\(UUID().uuidString)") + let destinationURL = documentsPath.appendingPathComponent("UTMUpdate-\(UUID().uuidString).dmg") do { try FileManager.default.moveItem(at: location, to: destinationURL) From b5e0f1a6d420cce536e289ef8cae1f577d674e39 Mon Sep 17 00:00:00 2001 From: Francesco146 Date: Wed, 20 Aug 2025 20:22:15 +0200 Subject: [PATCH 08/10] fix: simplify the installation process - opt for a simple DMG open instead of a more complicate in-place update --- Platform/Shared/UTMInstaller.swift | 146 ++++++++++++++++------------- 1 file changed, 80 insertions(+), 66 deletions(-) diff --git a/Platform/Shared/UTMInstaller.swift b/Platform/Shared/UTMInstaller.swift index 83fb6f320..382bc686d 100644 --- a/Platform/Shared/UTMInstaller.swift +++ b/Platform/Shared/UTMInstaller.swift @@ -34,19 +34,19 @@ class UTMInstaller { var errorDescription: String? { switch self { case .notAuthorized: - return "Installation not authorized" + return NSLocalizedString("Installation not authorized", comment: "UTMInstaller") case .invalidBundle: - return "Invalid application bundle" + return NSLocalizedString("Invalid application bundle", comment: "UTMInstaller") case .installationFailed(let reason): - return "Installation failed: \(reason)" + return String.localizedStringWithFormat(NSLocalizedString("Installation failed: %@", comment: "UTMInstaller"), reason) case .backupFailed: - return "Failed to create backup" + return NSLocalizedString("Failed to create backup", comment: "UTMInstaller") case .platformNotSupported: - return "Platform not supported for automatic updates" + return NSLocalizedString("Platform not supported for automatic updates", comment: "UTMInstaller") case .insufficientDiskSpace: - return "Insufficient disk space for installation" + return NSLocalizedString("Insufficient disk space for installation", comment: "UTMInstaller") case .mountFailed: - return "Failed to mount DMG file" + return NSLocalizedString("Failed to mount DMG file", comment: "UTMInstaller") } } } @@ -63,74 +63,88 @@ class UTMInstaller { #if os(macOS) private func openDMGAndShowInstructions(from downloadURL: URL) async throws { - // Mount the DMG - let mountPoint = try await mountDMG(downloadURL) - - // Show the mounted volume in Finder - try showMountedDMGInFinder(mountPoint) + // Try to mount the DMG + do { + _ = try await mountDMG(downloadURL) + } catch { + NSWorkspace.shared.open(downloadURL) + } // Show installation instructions to the user - try showInstallationInstructions() - } - - private func showMountedDMGInFinder(_ mountPoint: URL) throws { - NSWorkspace.shared.open(mountPoint) + await showInstallationInstructions() } - private func showInstallationInstructions() throws { - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = NSLocalizedString("Installation Instructions", comment: "UTMInstaller") - alert.informativeText = NSLocalizedString("Please follow these steps to complete the update:\n\n1. Save any unsaved work in your virtual machines\n2. Quit UTM completely\n3. Drag the new UTM.app from the opened DMG to your Applications folder (replace the existing version)\n4. Restart UTM\n\nThe DMG will remain mounted until you manually eject it.", comment: "UTMInstaller") - alert.addButton(withTitle: NSLocalizedString("OK", comment: "UTMInstaller")) - alert.addButton(withTitle: NSLocalizedString("Quit UTM Now", comment: "UTMInstaller")) - alert.alertStyle = .informational - - let response = alert.runModal() - - if response == .alertSecondButtonReturn { - // User chose to quit UTM now - NSApplication.shared.terminate(nil) + private func showInstallationInstructions() async { + return await withCheckedContinuation { continuation in + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = NSLocalizedString("Installation Instructions", comment: "UTMInstaller") + alert.informativeText = NSLocalizedString("Please follow these steps to complete the update:\n\n1. Save any unsaved work in your virtual machines\n2. Quit UTM completely\n3. Double-click the mounted DMG file to view its contents\n4. Drag the new UTM.app from the DMG to your Applications folder (replace the existing version)\n5. Restart UTM\n\nThe DMG file has been mounted and is ready for installation.", comment: "UTMInstaller") + alert.addButton(withTitle: NSLocalizedString("OK", comment: "UTMInstaller")) + alert.addButton(withTitle: NSLocalizedString("Quit UTM Now", comment: "UTMInstaller")) + alert.alertStyle = .informational + + let response = alert.runModal() + + if response == .alertSecondButtonReturn { + // close any existing alerts and quit + DispatchQueue.main.async { + // Close all open modal windows/alerts + for window in NSApplication.shared.windows { + window.close() + } + + NSApplication.shared.terminate(nil) + } + } + + continuation.resume(returning: ()) } } } - private func mountDMG(_ dmgURL: URL) async throws -> URL { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") - process.arguments = ["attach", dmgURL.path, "-nobrowse", "-quiet", "-plist"] - - print("process.arguments: \(process.arguments ?? [""])") - print("process.environment: \(process.environment ?? [:])") - print("Mounting DMG at: \(dmgURL.path)") - - - let pipe = Pipe() - process.standardOutput = pipe - - try process.run() - process.waitUntilExit() - - print("process.terminationStatus: \(process.terminationStatus)") - - guard process.terminationStatus == 0 else { - throw InstallationError.mountFailed - } - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], - let systemEntities = plist["system-entities"] as? [[String: Any]] else { - throw InstallationError.mountFailed - } - - for entity in systemEntities { - if let mountPoint = entity["mount-point"] as? String { - return URL(fileURLWithPath: mountPoint) + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.main.async { + // macOS will mount automatically + let success = NSWorkspace.shared.open(dmgURL) + + if success { + // wait a moment for the mount to complete + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + let volumeName = dmgURL.deletingPathExtension().lastPathComponent + let possiblePaths = [ + "/Volumes/\(volumeName)", + "/Volumes/UTM", + "/Volumes/UTM SE" + ] + + for path in possiblePaths { + if FileManager.default.fileExists(atPath: path) { + continuation.resume(returning: URL(fileURLWithPath: path)) + return + } + } + + // if we can't find a specific volume, check all volumes + let volumesURL = URL(fileURLWithPath: "/Volumes") + if let contents = try? FileManager.default.contentsOfDirectory(at: volumesURL, includingPropertiesForKeys: nil) { + for volumeURL in contents { + let appURL = volumeURL.appendingPathComponent("UTM.app") + if FileManager.default.fileExists(atPath: appURL.path) { + continuation.resume(returning: volumeURL) + return + } + } + } + + continuation.resume(throwing: InstallationError.mountFailed) + } + } else { + continuation.resume(throwing: InstallationError.mountFailed) + } } } - - throw InstallationError.mountFailed } #endif @@ -168,7 +182,7 @@ class UTMInstaller { private func redirectToAppStore() async throws { guard let appID = Bundle.main.infoDictionary?["UTMAppStoreID"] as? String, let url = URL(string: "itms-apps://itunes.apple.com/app/id\(appID)") else { - throw InstallationError.installationFailed("Cannot construct App Store URL") + throw InstallationError.installationFailed(NSLocalizedString("Cannot construct App Store URL", comment: "UTMInstaller")) } await UIApplication.shared.open(url) @@ -176,7 +190,7 @@ class UTMInstaller { private func showTestFlightUpdate() async throws { guard let url = URL(string: "itms-beta://") else { - throw InstallationError.installationFailed("Cannot open TestFlight") + throw InstallationError.installationFailed(NSLocalizedString("Cannot open TestFlight", comment: "UTMInstaller")) } await UIApplication.shared.open(url) From a4ceca2676a78fb0248282fb4ccc8c5fb2cdd093 Mon Sep 17 00:00:00 2001 From: Francesco146 Date: Wed, 20 Aug 2025 20:34:39 +0200 Subject: [PATCH 09/10] fix: update installation instructions and localized strings for clarity --- Platform/Shared/UTMInstaller.swift | 2 +- Platform/it.lproj/Localizable.strings | 8 +++++++- Platform/macOS/UpdateAvailableView.swift | 2 +- Platform/macOS/UpdateSettingsView.swift | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Platform/Shared/UTMInstaller.swift b/Platform/Shared/UTMInstaller.swift index 382bc686d..92b99ecf4 100644 --- a/Platform/Shared/UTMInstaller.swift +++ b/Platform/Shared/UTMInstaller.swift @@ -79,7 +79,7 @@ class UTMInstaller { DispatchQueue.main.async { let alert = NSAlert() alert.messageText = NSLocalizedString("Installation Instructions", comment: "UTMInstaller") - alert.informativeText = NSLocalizedString("Please follow these steps to complete the update:\n\n1. Save any unsaved work in your virtual machines\n2. Quit UTM completely\n3. Double-click the mounted DMG file to view its contents\n4. Drag the new UTM.app from the DMG to your Applications folder (replace the existing version)\n5. Restart UTM\n\nThe DMG file has been mounted and is ready for installation.", comment: "UTMInstaller") + alert.informativeText = NSLocalizedString("Please follow these steps to complete the update:\n\n1. Save any unsaved work in your virtual machines\n2. Quit UTM completely\n3. Drag the new UTM.app from the DMG to your Applications folder (replace the existing version)\n4. Restart UTM\n\nThe DMG file has been mounted and is ready for installation.", comment: "UTMInstaller") alert.addButton(withTitle: NSLocalizedString("OK", comment: "UTMInstaller")) alert.addButton(withTitle: NSLocalizedString("Quit UTM Now", comment: "UTMInstaller")) alert.alertStyle = .informational diff --git a/Platform/it.lproj/Localizable.strings b/Platform/it.lproj/Localizable.strings index 12fd8524d..d943de612 100644 --- a/Platform/it.lproj/Localizable.strings +++ b/Platform/it.lproj/Localizable.strings @@ -2330,4 +2330,10 @@ "Cannot open TestFlight" = "Impossibile aprire TestFlight"; /* UpdateSettingsView/UpdateAvailableView */ -"Released: %@" = "Rilasciato %@"; +"Released: %@" = "Rilasciato: %@"; + +"Please follow these steps to complete the update:\n\n1. Save any unsaved work in your virtual machines\n2. Quit UTM completely\n3. Drag the new UTM.app from the DMG to your Applications folder (replace the existing version)\n4. Restart UTM\n\nThe DMG file has been mounted and is ready for installation." = "Segui questi passaggi per completare l'aggiornamento:\n\n1. Salva eventuali lavori non salvati nelle tue macchine virtuali\n2. Esci completamente da UTM\n3. Trascina il nuovo UTM.app dal DMG nella tua cartella Applicazioni (sostituisci la versione esistente)\n4. Riavvia UTM\n\nIl file DMG è stato montato ed è pronto per l'installazione."; + +"Quit UTM Now" = "Esci da UTM ora"; + +"Installation Instructions" = "Istruzioni per l'installazione"; diff --git a/Platform/macOS/UpdateAvailableView.swift b/Platform/macOS/UpdateAvailableView.swift index fff19a40e..904ece965 100644 --- a/Platform/macOS/UpdateAvailableView.swift +++ b/Platform/macOS/UpdateAvailableView.swift @@ -40,7 +40,7 @@ struct UpdateAvailableView: View { .foregroundColor(.secondary) HStack { - Text(String.localizedStringWithFormat(NSLocalizedString("Released %@", comment: "UpdateAvailableView"), updateInfo.releaseDate.abbreviatedDateString)) + Text(String.localizedStringWithFormat(NSLocalizedString("Released: %@", comment: "UpdateAvailableView"), updateInfo.releaseDate.abbreviatedDateString)) if updateInfo.isCritical { diff --git a/Platform/macOS/UpdateSettingsView.swift b/Platform/macOS/UpdateSettingsView.swift index ded3d42a5..efee5482d 100644 --- a/Platform/macOS/UpdateSettingsView.swift +++ b/Platform/macOS/UpdateSettingsView.swift @@ -193,7 +193,7 @@ struct UpdateAvailableSection: View { } } - Text(String.localizedStringWithFormat(NSLocalizedString("Released %@", comment: "UpdateSettingsView"), updateInfo.releaseDate.abbreviatedDateString)) + Text(String.localizedStringWithFormat(NSLocalizedString("Released: %@", comment: "UpdateSettingsView"), updateInfo.releaseDate.abbreviatedDateString)) .font(.caption) .foregroundColor(.secondary) From 1167a3a8a0bc761e8d280197e860f82633b8c861 Mon Sep 17 00:00:00 2001 From: Francesco146 Date: Wed, 20 Aug 2025 20:38:10 +0200 Subject: [PATCH 10/10] fix: refine critical update detection logic --- Platform/Shared/UTMUpdateManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Platform/Shared/UTMUpdateManager.swift b/Platform/Shared/UTMUpdateManager.swift index 008078c36..6be182bce 100644 --- a/Platform/Shared/UTMUpdateManager.swift +++ b/Platform/Shared/UTMUpdateManager.swift @@ -277,7 +277,7 @@ class UTMUpdateManager: UTMReleaseHelper { } let isPrerelease = json["prerelease"] as? Bool ?? false - let isCritical = body.lowercased().contains("critical") || body.lowercased().contains("security") + let isCritical = body.lowercased().contains("critical") return UpdateInfo( version: version,