2
0

UTMReleaseHelper.swift 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. //
  2. // Copyright © 2023 osy. All rights reserved.
  3. //
  4. // Licensed under the Apache License, Version 2.0 (the "License");
  5. // you may not use this file except in compliance with the License.
  6. // You may obtain a copy of the License at
  7. //
  8. // http://www.apache.org/licenses/LICENSE-2.0
  9. //
  10. // Unless required by applicable law or agreed to in writing, software
  11. // distributed under the License is distributed on an "AS IS" BASIS,
  12. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. // See the License for the specific language governing permissions and
  14. // limitations under the License.
  15. //
  16. import SwiftUI
  17. @MainActor
  18. class UTMReleaseHelper: ObservableObject {
  19. struct Section: Identifiable {
  20. var title: String = ""
  21. var body: [String] = []
  22. let id: UUID = UUID()
  23. var isEmpty: Bool {
  24. title.isEmpty && body.isEmpty
  25. }
  26. }
  27. private enum ReleaseError: Error {
  28. case fetchFailed
  29. }
  30. @Setting("ReleaseNotesLastVersion") private var releaseNotesLastVersion: String? = nil
  31. @Published var isReleaseNotesShown: Bool = false
  32. @Published var releaseNotes: [Section] = []
  33. var currentVersion: String {
  34. Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
  35. }
  36. func fetchReleaseNotes(force: Bool = false) async {
  37. guard force || releaseNotesLastVersion != currentVersion else {
  38. return
  39. }
  40. let configuration = URLSessionConfiguration.ephemeral
  41. configuration.allowsCellularAccess = true
  42. configuration.allowsExpensiveNetworkAccess = false
  43. configuration.allowsConstrainedNetworkAccess = false
  44. configuration.waitsForConnectivity = false
  45. configuration.httpAdditionalHeaders = ["Accept": "application/vnd.github+json",
  46. "X-GitHub-Api-Version": "2022-11-28"]
  47. let session = URLSession(configuration: configuration)
  48. let url = "https://api.github.com/repos/utmapp/UTM/releases/tags/v\(currentVersion)"
  49. do {
  50. try await Task.detached(priority: .utility) {
  51. let (data, _) = try await session.data(from: URL(string: url)!)
  52. if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let body = json["body"] as? String {
  53. await self.parseReleaseNotes(body)
  54. } else {
  55. throw ReleaseError.fetchFailed
  56. }
  57. }.value
  58. } catch {
  59. logger.error("Failed to download release notes: \(error.localizedDescription)")
  60. if force {
  61. updateReleaseNotes([])
  62. } else {
  63. // do not try to download again for this release
  64. releaseNotesLastVersion = currentVersion
  65. }
  66. }
  67. }
  68. nonisolated func parseReleaseNotes(_ notes: String) async {
  69. let lines = notes.split(whereSeparator: \.isNewline)
  70. var sections = [Section]()
  71. var currentSection = Section()
  72. for line in lines {
  73. let string = String(line)
  74. let nsString = string as NSString
  75. if line.hasPrefix("## ") {
  76. if !currentSection.isEmpty {
  77. sections.append(currentSection)
  78. }
  79. let index = line.index(line.startIndex, offsetBy: 3)
  80. currentSection = Section(title: String(line[index...]))
  81. } else if let regex = try? NSRegularExpression(pattern: #"^\* \(([^\)]+)\) "#),
  82. let match = regex.firstMatch(in: string, range: NSRange(location: 0, length: nsString.length)),
  83. match.numberOfRanges > 1 {
  84. let range = match.range(at: 1)
  85. let platform = nsString.substring(with: range)
  86. let description = nsString.substring(from: match.range.location + match.range.length)
  87. #if os(iOS) || os(visionOS)
  88. #if WITH_QEMU_TCI
  89. if platform == "iOS SE" {
  90. currentSection.body.append(description)
  91. }
  92. #elseif WITH_REMOTE
  93. if platform == "iOS Remote" {
  94. currentSection.body.append(description)
  95. }
  96. #endif
  97. #if os(visionOS)
  98. if platform.hasPrefix("visionOS") {
  99. currentSection.body.append(description)
  100. }
  101. #endif
  102. if platform != "iOS SE" && platform.hasPrefix("iOS") {
  103. // should we also parse versions?
  104. currentSection.body.append(description)
  105. }
  106. #elseif os(macOS)
  107. if platform.hasPrefix("macOS") {
  108. currentSection.body.append(description)
  109. }
  110. #else
  111. currentSection.body.append(description)
  112. #endif
  113. } else if line.hasPrefix("* ") {
  114. let index = line.index(line.startIndex, offsetBy: 2)
  115. currentSection.body.append(String(line[index...]))
  116. } else {
  117. currentSection.body.append(String(line))
  118. }
  119. }
  120. if !currentSection.isEmpty {
  121. sections.append(currentSection)
  122. }
  123. if !sections.isEmpty {
  124. await updateReleaseNotes(sections)
  125. }
  126. }
  127. private func updateReleaseNotes(_ sections: [Section]) {
  128. releaseNotes = sections
  129. isReleaseNotesShown = true
  130. }
  131. func closeReleaseNotes() {
  132. releaseNotesLastVersion = currentVersion
  133. isReleaseNotesShown = false
  134. }
  135. }