Skip to content

Commit

Permalink
home: parse and display release notes
Browse files Browse the repository at this point in the history
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
osy committed Apr 23, 2023
1 parent b8a4398 commit db58b58
Show file tree
Hide file tree
Showing 6 changed files with 364 additions and 4 deletions.
14 changes: 14 additions & 0 deletions Platform/Shared/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ let productName = "UTM"
struct ContentView: View {
@State private var editMode = false
@EnvironmentObject private var data: UTMData
@StateObject private var releaseHelper = UTMReleaseHelper()
@State private var newPopupPresented = false
@State private var openSheetPresented = false
@Environment(\.openURL) var openURL
Expand All @@ -40,6 +41,16 @@ struct ContentView: View {
.frame(minWidth: 800, idealWidth: 1200, minHeight: 600, idealHeight: 800)
#endif
.disabled(data.busy && !data.showNewVMSheet && !data.showSettingsModal)
.sheet(isPresented: $releaseHelper.isReleaseNotesShown, onDismiss: {
releaseHelper.closeReleaseNotes()
}, content: {
VMReleaseNotesView(helper: releaseHelper).padding()
})
.onReceive(NSNotification.ShowReleaseNotes) { _ in
Task {
await releaseHelper.fetchReleaseNotes(force: true)
}
}
.onOpenURL(perform: handleURL)
.handlesExternalEvents(preferring: ["*"], allowing: ["*"])
.onReceive(NSNotification.NewVirtualMachine) { _ in
Expand All @@ -57,6 +68,9 @@ struct ContentView: View {
Task {
await data.listRefresh()
}
Task {
await releaseHelper.fetchReleaseNotes()
}
#if os(macOS)
NSWindow.allowsAutomaticWindowTabbing = false
#else
Expand Down
138 changes: 138 additions & 0 deletions Platform/Shared/UTMReleaseHelper.swift
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
}
}
10 changes: 7 additions & 3 deletions Platform/Shared/VMCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,18 @@ struct VMCommands: Commands {
SidebarCommands()
ToolbarCommands()
CommandGroup(replacing: .help) {
Button(action: { NotificationCenter.default.post(name: NSNotification.ShowReleaseNotes, object: nil) }, label: {
Text("What's New")
}).keyboardShortcut(KeyEquivalent("1"), modifiers: [.command, .control])
Button(action: { openLink("https://mac.getutm.app/gallery/") }, label: {
Text("Virtual Machine Gallery")
}).keyboardShortcut(KeyEquivalent("1"), modifiers: [.command, .control])
}).keyboardShortcut(KeyEquivalent("2"), modifiers: [.command, .control])
Button(action: { openLink("https://docs.getutm.app/") }, label: {
Text("Support")
}).keyboardShortcut(KeyEquivalent("2"), modifiers: [.command, .control])
}).keyboardShortcut(KeyEquivalent("3"), modifiers: [.command, .control])
Button(action: { openLink("https://mac.getutm.app/licenses/") }, label: {
Text("License")
}).keyboardShortcut(KeyEquivalent("3"), modifiers: [.command, .control])
}).keyboardShortcut(KeyEquivalent("4"), modifiers: [.command, .control])
}
}

Expand All @@ -52,4 +55,5 @@ struct VMCommands: Commands {
extension NSNotification {
static let NewVirtualMachine = NSNotification.Name("NewVirtualMachine")
static let OpenVirtualMachine = NSNotification.Name("OpenVirtualMachine")
static let ShowReleaseNotes = NSNotification.Name("ShowReleaseNotes")
}
188 changes: 188 additions & 0 deletions Platform/Shared/VMReleaseNotesView.swift
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())
}
}
Loading

0 comments on commit db58b58

Please sign in to comment.