-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
home: parse and display release notes
Detect if an update had taken place when the last viewed release notes version differs from the current version. Download the release notes from GitHub API and parse it to filter out items for other platforms. Display the notes in a sheet.
- Loading branch information
Showing
6 changed files
with
364 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
// | ||
// Copyright © 2023 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 | ||
|
||
@MainActor | ||
class UTMReleaseHelper: ObservableObject { | ||
struct Section: Identifiable { | ||
var title: String = "" | ||
var body: [String] = [] | ||
|
||
let id: UUID = UUID() | ||
|
||
var isEmpty: Bool { | ||
title.isEmpty && body.isEmpty | ||
} | ||
} | ||
|
||
private enum ReleaseError: Error { | ||
case fetchFailed | ||
} | ||
|
||
@Setting("ReleaseNotesLastVersion") private var releaseNotesLastVersion: String? = nil | ||
|
||
@Published var isReleaseNotesShown: Bool = false | ||
@Published var releaseNotes: [Section] = [] | ||
|
||
var currentVersion: String { | ||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" | ||
} | ||
|
||
func fetchReleaseNotes(force: Bool = false) async { | ||
guard force || releaseNotesLastVersion != currentVersion else { | ||
return | ||
} | ||
let configuration = URLSessionConfiguration.ephemeral | ||
configuration.allowsCellularAccess = false | ||
configuration.allowsExpensiveNetworkAccess = false | ||
configuration.allowsConstrainedNetworkAccess = false | ||
configuration.waitsForConnectivity = true | ||
configuration.httpAdditionalHeaders = ["Accept": "application/vnd.github+json", | ||
"X-GitHub-Api-Version": "2022-11-28"] | ||
let session = URLSession(configuration: configuration) | ||
let url = "https://api.github.com/repos/utmapp/UTM/releases/tags/v\(currentVersion)" | ||
do { | ||
try await Task.detached(priority: .utility) { | ||
let (data, _) = try await session.data(from: URL(string: url)!) | ||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let body = json["body"] as? String { | ||
await self.parseReleaseNotes(body) | ||
} else { | ||
throw ReleaseError.fetchFailed | ||
} | ||
}.value | ||
} catch { | ||
logger.error("Failed to download release notes: \(error.localizedDescription)") | ||
if force { | ||
updateReleaseNotes([]) | ||
} else { | ||
// do not try to download again for this release | ||
releaseNotesLastVersion = currentVersion | ||
} | ||
} | ||
} | ||
|
||
nonisolated func parseReleaseNotes(_ notes: String) async { | ||
let lines = notes.split(whereSeparator: \.isNewline) | ||
var sections = [Section]() | ||
var currentSection = Section() | ||
for line in lines { | ||
let string = String(line) | ||
let nsString = string as NSString | ||
if line.hasPrefix("## ") { | ||
if !currentSection.isEmpty { | ||
sections.append(currentSection) | ||
} | ||
let index = line.index(line.startIndex, offsetBy: 3) | ||
currentSection = Section(title: String(line[index...])) | ||
} else if let regex = try? NSRegularExpression(pattern: #"^\* \(([^\)]+)\) "#), | ||
let match = regex.firstMatch(in: string, range: NSRange(location: 0, length: nsString.length)), | ||
match.numberOfRanges > 1 { | ||
let range = match.range(at: 1) | ||
let platform = nsString.substring(with: range) | ||
let description = nsString.substring(from: match.range.location + match.range.length) | ||
#if os(iOS) | ||
#if WITH_QEMU_TCI | ||
if platform == "iOS SE" { | ||
currentSection.body.append(description) | ||
} | ||
#endif | ||
if platform != "iOS SE" && platform.hasPrefix("iOS") { | ||
// should we also parse versions? | ||
currentSection.body.append(description) | ||
} | ||
#elseif os(macOS) | ||
if platform.hasPrefix("macOS") { | ||
currentSection.body.append(description) | ||
} | ||
#else | ||
currentSection.body.append(description) | ||
#endif | ||
} else if line.hasPrefix("* ") { | ||
let index = line.index(line.startIndex, offsetBy: 2) | ||
currentSection.body.append(String(line[index...])) | ||
} else { | ||
currentSection.body.append(String(line)) | ||
} | ||
} | ||
if !currentSection.isEmpty { | ||
sections.append(currentSection) | ||
} | ||
if !sections.isEmpty { | ||
await updateReleaseNotes(sections) | ||
} | ||
} | ||
|
||
private func updateReleaseNotes(_ sections: [Section]) { | ||
releaseNotes = sections | ||
isReleaseNotesShown = true | ||
} | ||
|
||
func closeReleaseNotes() { | ||
releaseNotesLastVersion = currentVersion | ||
isReleaseNotesShown = false | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,188 @@ | ||
// | ||
// Copyright © 2023 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 VMReleaseNotesView: View { | ||
@ObservedObject var helper: UTMReleaseHelper | ||
@State private var isShowAll: Bool = false | ||
@Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode> | ||
|
||
let ignoreSections = ["Highlights", "Installation"] | ||
|
||
var body: some View { | ||
VStack { | ||
if helper.releaseNotes.count > 0 { | ||
ScrollView { | ||
Text("What's New") | ||
.font(.largeTitle) | ||
.padding(.bottom) | ||
VStack(alignment: .leading) { | ||
Notes(section: helper.releaseNotes.first!, isProminent: true) | ||
.padding(.bottom, 0.5) | ||
if isShowAll { | ||
ForEach(helper.releaseNotes) { section in | ||
if !ignoreSections.contains(section.title) { | ||
Notes(section: section) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} else { | ||
VStack { | ||
Spacer() | ||
HStack { | ||
Spacer() | ||
Text("No release notes found for version \(helper.currentVersion).") | ||
.font(.headline) | ||
Spacer() | ||
} | ||
Spacer() | ||
} | ||
} | ||
Spacer() | ||
Buttons { | ||
if !isShowAll { | ||
Button { | ||
isShowAll = true | ||
} label: { | ||
Text("Show All") | ||
#if os(iOS) | ||
.frame(maxWidth: .infinity) | ||
#endif | ||
}.buttonStyle(ReleaseButtonStyle()) | ||
} | ||
Button { | ||
presentationMode.wrappedValue.dismiss() | ||
} label: { | ||
Text("Continue") | ||
#if os(iOS) | ||
.frame(maxWidth: .infinity) | ||
#endif | ||
}.keyboardShortcut(.defaultAction) | ||
.buttonStyle(ReleaseButtonStyle(isProminent: true)) | ||
} | ||
} | ||
#if os(macOS) | ||
.frame(width: 450, height: 450) | ||
#endif | ||
.onAppear { | ||
if helper.releaseNotes.count == 0 { | ||
isShowAll = true | ||
} else if helper.releaseNotes.first!.body.count == 0 { | ||
//isShowAll = true | ||
} | ||
} | ||
} | ||
} | ||
|
||
private struct Notes: View { | ||
let section: UTMReleaseHelper.Section | ||
@State var isProminent: Bool = false | ||
|
||
private var hasBullet: Bool { | ||
!isProminent && section.body.count > 1 | ||
} | ||
|
||
var body: some View { | ||
if !isProminent { | ||
Text(section.title) | ||
.font(.title2) | ||
.padding([.top, .bottom]) | ||
} | ||
ForEach(section.body) { description in | ||
HStack(alignment: .top) { | ||
if hasBullet { | ||
Text("\u{2022} ") | ||
} | ||
if #available(iOS 15, macOS 12, *), let attributed = try? AttributedString(markdown: description) { | ||
Text(attributed) | ||
} else { | ||
Text(description) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
private struct Buttons<Content>: View where Content: View { | ||
var content: () -> Content | ||
|
||
init(@ViewBuilder content: @escaping () -> Content) { | ||
self.content = content | ||
} | ||
|
||
var body: some View { | ||
#if os(macOS) | ||
HStack { | ||
Spacer() | ||
content() | ||
} | ||
#else | ||
VStack { | ||
if #available(iOS 15, *) { | ||
content() | ||
.buttonStyle(.bordered) | ||
} else { | ||
content() | ||
} | ||
} | ||
#endif | ||
} | ||
} | ||
|
||
private struct ReleaseButtonStyle: PrimitiveButtonStyle { | ||
private let isProminent: Bool | ||
private let backgroundColor: Color | ||
private let foregroundColor: Color | ||
|
||
init(isProminent: Bool = false) { | ||
self.isProminent = isProminent | ||
self.backgroundColor = isProminent ? .accentColor : .gray | ||
self.foregroundColor = isProminent ? .white : .white | ||
} | ||
|
||
func makeBody(configuration: Self.Configuration) -> some View { | ||
#if os(macOS) | ||
DefaultButtonStyle().makeBody(configuration: configuration) | ||
#else | ||
if #available(iOS 15, *) { | ||
if isProminent { | ||
BorderedProminentButtonStyle().makeBody(configuration: configuration) | ||
} else { | ||
BorderedButtonStyle().makeBody(configuration: configuration) | ||
} | ||
} else { | ||
DefaultButtonStyle().makeBody(configuration: configuration) | ||
.padding() | ||
.foregroundColor(foregroundColor) | ||
.background(backgroundColor) | ||
.cornerRadius(6) | ||
.overlay( | ||
RoundedRectangle(cornerRadius: 6) | ||
.stroke(foregroundColor, lineWidth: 1) | ||
) | ||
} | ||
#endif | ||
} | ||
} | ||
|
||
struct VMReleaseNotesView_Previews: PreviewProvider { | ||
static var previews: some View { | ||
VMReleaseNotesView(helper: UTMReleaseHelper()) | ||
} | ||
} |
Oops, something went wrong.